├── screenshot.png ├── Tests ├── LinuxMain.swift └── SuggestionPopupTests │ ├── XCTestManifests.swift │ └── SuggestionPopupTests.swift ├── Sources └── SuggestionPopup │ ├── KeyCodes.swift │ ├── SearchCompleter │ ├── Suggestion.swift │ ├── LocationSearchCompleter.swift │ └── SearchCompleter.swift │ ├── WindowController │ ├── SuggestionWindow.swift │ └── SuggestionWindowController.swift │ └── ViewController │ ├── SuggestionTableRowView.swift │ ├── SuggestionListView.swift │ ├── SuggestionTableCellView.swift │ └── SuggestionListViewController.swift ├── Package.swift ├── README.md ├── .gitignore └── LICENSE /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Schlaubischlump/SuggestionPopup/HEAD/screenshot.png -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SuggestionPopupTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SuggestionPopupTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SuggestionPopupTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SuggestionPopupTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/KeyCodes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyCodes.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 27.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | enum KeyCodes: UInt16 { 11 | case `return` = 36 12 | case tab = 48 13 | case arrowLeft = 123 14 | case arrowRight = 124 15 | case arrowDown = 125 16 | case arrowUp = 126 17 | } 18 | 19 | /// Define a class to be able to handle key events. 20 | protocol KeyResponder { 21 | func processKeys(with theEvent: NSEvent) -> NSEvent? 22 | } 23 | -------------------------------------------------------------------------------- /Tests/SuggestionPopupTests/SuggestionPopupTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SuggestionPopup 3 | 4 | final class SuggestionPopupTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | //XCTAssertEqual(SuggestionPopup().text, "Hello, World!") 10 | } 11 | 12 | static var allTests = [ 13 | ("testExample", testExample), 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/SearchCompleter/Suggestion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutocompleteMatch.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | public typealias SuggestionSelectAction = ((String, Suggestion) -> Void) 11 | public typealias SuggestionHighlightAction = ((String, Suggestion?) -> Void) 12 | public typealias SuggestionShowAction = (() -> Void) 13 | public typealias SuggestionHideAction = (() -> Void) 14 | public typealias SuggestionFirstResponderAction = (() -> Void) 15 | 16 | /// Your class must conform to this protocol to be displayed in the suggestion list. 17 | public protocol Suggestion: NSObject { 18 | /// The main title. 19 | var title: String { get } 20 | /// The subtitle below the title. 21 | var subtitle: String { get } 22 | /// The image to the left. 23 | var image: NSImage? { get } 24 | /// Optional range to highlight inside the title. 25 | var highlightedTitleRanges: [Range] { get } 26 | /// Optional range to highlight inside the subtitle. 27 | var highlightedSubtitleRanges: [Range] { get } 28 | } 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SuggestionPopup", 8 | platforms: [ 9 | .macOS(.v10_12) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "SuggestionPopup", 15 | targets: ["SuggestionPopup"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "SuggestionPopup", 26 | dependencies: []), 27 | .testTarget( 28 | name: "SuggestionPopupTests", 29 | dependencies: ["SuggestionPopup"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/WindowController/SuggestionWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutocompleteWindow.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | class SuggestionWindow: NSWindow { 11 | 12 | // MARK: - Constructor 13 | 14 | /// Create a bordless, transparent window which hosts the popup. 15 | init() { 16 | super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: true) 17 | // Configure the window 18 | self.hasShadow = true 19 | self.backgroundColor = .clear 20 | self.isOpaque = false 21 | self.isMovable = false 22 | self.isMovableByWindowBackground = false 23 | // Assign the contentViewController. 24 | self.contentViewController = SuggestionListViewController() 25 | } 26 | 27 | // MARK: - Spinner 28 | 29 | func showSpinner() { 30 | // Use a fixed height. 31 | var size = self.frame.size 32 | size.height = 40 33 | self.setContentSize(size) 34 | let contentViewController = self.contentViewController as? SuggestionListViewController 35 | contentViewController?.showSpinner() 36 | } 37 | 38 | func hideSpinner() { 39 | let contentViewController = self.contentViewController as? SuggestionListViewController 40 | contentViewController?.hideSpinner() 41 | } 42 | 43 | // MARK: - Results 44 | 45 | func setSuggestions(_ suggestions: [Suggestion]) { 46 | let contentViewController = self.contentViewController as? SuggestionListViewController 47 | // Update the results. 48 | contentViewController?.setSuggestions(suggestions) 49 | // Update the content size. 50 | let contentSize = contentViewController?.getSuggestedWindowSize() ?? .zero 51 | let bottomLeftPoint = CGPoint(x: self.frame.minX, y: self.frame.maxY - contentSize.height) 52 | self.setFrame(CGRect(origin: bottomLeftPoint, size: contentSize), display: true) 53 | } 54 | } 55 | 56 | // MARK: - Accessibility 57 | extension SuggestionWindow { 58 | /// We ignore this window for accessibility. 59 | override func isAccessibilityElement() -> Bool { 60 | return false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/ViewController/SuggestionTableRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutoCompleteTableRowView.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 27.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | class SuggestionTableRowView: NSTableRowView { 11 | /// Reference to the parent table view. 12 | weak var tableView: NSTableView? 13 | /// The row number. 14 | var row: Int 15 | 16 | /// Inform the cell if it should be highlighted. 17 | override var isSelected: Bool { 18 | didSet { 19 | guard self.numberOfColumns > 0 else { return } 20 | // Update the cells highlight state. 21 | let cellView = self.view(atColumn: 0) as? SuggestionTableCellView 22 | cellView?.isHighlighted = self.isSelected 23 | } 24 | } 25 | 26 | /// Always use a blue highlight for the cells. 27 | override var isEmphasized: Bool { 28 | get { return true } 29 | set {} 30 | } 31 | 32 | // MARK: - Constructor 33 | 34 | init(tableView: NSTableView, row: Int) { 35 | self.tableView = tableView 36 | self.row = row 37 | super.init(frame: .zero) 38 | } 39 | 40 | required init?(coder: NSCoder) { 41 | fatalError("InitWithCoder not available.") 42 | } 43 | 44 | // MARK: - Hover 45 | 46 | override func updateTrackingAreas() { 47 | super.updateTrackingAreas() 48 | // Define the traking area to execute the mouseEntered and mouseExited events. 49 | for trackingArea in self.trackingAreas { 50 | self.removeTrackingArea(trackingArea) 51 | } 52 | let options: NSTrackingArea.Options = [.mouseMoved, .mouseEnteredAndExited, .activeInActiveApp] 53 | let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) 54 | self.addTrackingArea(trackingArea) 55 | } 56 | 57 | override func mouseMoved(with event: NSEvent) { 58 | super.mouseMoved(with: event) 59 | self.tableView?.selectRowIndexes([row], byExtendingSelection: false) 60 | } 61 | 62 | override func mouseExited(with event: NSEvent) { 63 | super.mouseExited(with: event) 64 | self.tableView?.selectRowIndexes([], byExtendingSelection: false) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/SearchCompleter/LocationSearchCompleter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationSearchController.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | import MapKit 10 | 11 | 12 | extension MKLocalSearchCompletion: Suggestion { 13 | // Highlight the matched string inside the title. 14 | public var highlightedTitleRanges: [Range] { 15 | return self.titleHighlightRanges.compactMap { Range($0.rangeValue) } 16 | } 17 | 18 | // Highlight the matched string inside the subtitle. 19 | public var highlightedSubtitleRanges: [Range] { 20 | return self.subtitleHighlightRanges.compactMap { Range($0.rangeValue) } 21 | } 22 | 23 | // We don't show any Image. 24 | public var image: NSImage? { 25 | return nil 26 | } 27 | 28 | } 29 | 30 | /// A simple search completer which searches for locations. 31 | public final class LocationSearchCompleter: SearchCompleter { 32 | /// Search completer to find a location based on a string. 33 | private var searchCompleter = MKLocalSearchCompleter() 34 | 35 | // Setup the search completer. 36 | public override func setup() { 37 | if #available(OSX 10.15, *) { 38 | self.searchCompleter.resultTypes = .address 39 | } else { 40 | self.searchCompleter.filterType = .locationsOnly 41 | } 42 | self.searchCompleter.delegate = self 43 | } 44 | 45 | // Prepare the search results and show the spinner. 46 | public override func prepareSuggestions(for searchString: String) { 47 | // Show a progress spinner. 48 | self.showSpinner() 49 | // Cancel any running search request. 50 | if self.searchCompleter.isSearching { 51 | self.searchCompleter.cancel() 52 | } 53 | // Start a search. 54 | self.searchCompleter.queryFragment = searchString 55 | } 56 | 57 | // Show the results and hide the spinner. 58 | public override func setSuggestions(_ suggestions: [Suggestion]) { 59 | self.hideSpinner() 60 | super.setSuggestions(suggestions) 61 | } 62 | } 63 | 64 | extension LocationSearchCompleter: MKLocalSearchCompleterDelegate { 65 | /// Called when the searchCompleter finished loading the search results. 66 | public func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { 67 | self.setSuggestions(self.searchCompleter.results) 68 | } 69 | 70 | public func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { 71 | self.setSuggestions([]) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SuggestionPopup 2 | 3 | [![License: GNU Affero General Public license version 3](https://img.shields.io/badge/License-LGPLv3-blue.svg)](https://opensource.org/licenses/lgpl-3.0) 4 | 5 | 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, 11.0. MacOS 10.13-10.14 could work. I will test this in the future and make it compatible, if required. This software is still in beta. 6 | 7 | 8 | 9 | Usage: 10 | If you just want to have a simple location search, things are easy: 11 | 12 | ``` Swift 13 | // Keep a reference to the search completer in memory. 14 | var searchCompleter: LocationSearchCompleter! 15 | ... 16 | // Somewhere in your constructor create a LocationSearchCompleter with 17 | // your textField. You can still use the textField delegate ! 18 | self.searchCompleter = LocationSearchCompleter(searchField: searchField) 19 | self.searchCompleter.onShow = { ... } 20 | self.searchCompleter.onHide = { ... } 21 | self.searchCompleter.onHighlight = { ... } 22 | self.searchCompleter.onSelect = { ... } 23 | ``` 24 | 25 | If you want a custom search, things are a little bit more difficult. 26 | 27 | ``` Swift 28 | // Create or implement a new class based on NSObject which conforms 29 | // to the `Suggestion` protocol. A simple new class could look like 30 | // this: 31 | class SimpleSuggestion: NSObject, Suggestion { 32 | init(title: String = "", subtitle: String = "", image: NSImage? = nil) { 33 | self.title = title 34 | self.subtitle = subtitle 35 | self.image = image 36 | } 37 | 38 | var title: String = "" 39 | var subtitle: String = "" 40 | var image: NSImage? 41 | var highlightedTitleRanges: [Range] = [] 42 | var highlightedSubtitleRanges: [Range] = [] 43 | } 44 | // Most of the times it might be easier to just extend your existing class. 45 | // Take a look at the `LocationSearchCompleter` to see a simple example. 46 | 47 | 48 | // Create a new subclass of the SearchCompleter class 49 | class SimpleSearchCompleter: SearchCompleter { 50 | 51 | // This is called on `init`. It is just for your convenience. 52 | // Place all initial setup code here. 53 | override func setup() { 54 | 55 | } 56 | 57 | // Override this function to prepare your search. If your search 58 | // is compute intensive, use a background thread here and call 59 | // `setSuggestions` on completion. You might show a progress spinner 60 | // in this case. For a simple search, just place your code here 61 | // and end the function with a `setSuggestions` call. 62 | override func prepareSuggestions(for searchString: String) { 63 | //self.showSpinner() 64 | super.prepareSuggestions(for: searchString) 65 | } 66 | 67 | // Call this function to show the search result. You might override 68 | // it to hide the progress spinner. 69 | override func setSuggestions(_ suggestions: [Suggestion]) { 70 | //self.hideSpinner() 71 | super.setSuggestions(suggestions) 72 | } 73 | } 74 | ``` 75 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/ViewController/SuggestionListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutocompleteContentView.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | class SuggestionListView: NSScrollView { 11 | /// The main table view. 12 | var tableView: NSTableView! 13 | /// The main table view column. 14 | var column: NSTableColumn! 15 | 16 | /// Always force overlay style, even on 10.15.x, 11.x and 12.x 17 | override var scrollerStyle: NSScroller.Style { 18 | get { return .overlay } 19 | set { } 20 | } 21 | 22 | override init(frame frameRect: NSRect) { 23 | super.init(frame: frameRect) 24 | 25 | // Setup the tableView. 26 | self.tableView = NSTableView(frame: .zero) 27 | var insetBottom: CGFloat = 5 28 | if #available(OSX 13.0, *) { 29 | self.tableView.style = .inset 30 | insetBottom = 10 31 | } else if #available(OSX 11.0, *) { 32 | self.tableView.style = .inset 33 | insetBottom = 0 34 | } 35 | 36 | self.tableView.selectionHighlightStyle = .regular 37 | self.tableView.backgroundColor = .clear 38 | self.tableView.rowSizeStyle = .custom 39 | self.tableView.rowHeight = 36.0 40 | self.tableView.intercellSpacing = NSSize(width: 5.0, height: 0.0) 41 | self.tableView.headerView = nil 42 | 43 | // Add a table column. 44 | self.column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "text")) 45 | self.column.isEditable = false 46 | self.tableView.addTableColumn(self.column) 47 | 48 | // Setup the scrollView. 49 | self.documentView = self.tableView 50 | 51 | self.drawsBackground = false 52 | self.hasVerticalScroller = true 53 | self.hasHorizontalScroller = false 54 | //self.verticalScroller?.controlSize = .small 55 | self.automaticallyAdjustsContentInsets = false 56 | self.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: insetBottom, right: 0) 57 | self.scrollerInsets = NSEdgeInsets(top: 0, left: 0, bottom: -insetBottom, right: 0) 58 | } 59 | 60 | required init?(coder: NSCoder) { 61 | fatalError("InitWithCoder not supported.") 62 | } 63 | 64 | // MARK: - Layout 65 | 66 | /// Not required for macOS 10.13 and up 67 | override func layout() { 68 | super.layout() 69 | let width = self.frame.width 70 | // fix size on macOS Big Sur 71 | self.tableView.sizeToFit() 72 | self.tableView.frame.size.width = width 73 | self.contentView.frame.size.width = width 74 | } 75 | 76 | // MARK: - Helper 77 | 78 | func selectPreviousRow() { 79 | let row = self.tableView.selectedRow 80 | if row > 0 { 81 | self.tableView.selectRowIndexes([row-1], byExtendingSelection: false) 82 | self.tableView.scrollRowToVisible(row-1) 83 | } else { 84 | self.tableView.selectRowIndexes([], byExtendingSelection: false) 85 | } 86 | } 87 | 88 | func selectNextRow() { 89 | let row = self.tableView.selectedRow 90 | guard row < self.tableView.numberOfRows-1 else { return } 91 | self.tableView.selectRowIndexes([row+1], byExtendingSelection: false) 92 | self.tableView.scrollRowToVisible(row+1) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Apple Owned files 6 | *.dmg 7 | *.dmg.signature 8 | .DS_Store 9 | .swiftpm/* 10 | 11 | ## Build generated 12 | build/ 13 | DerivedData/ 14 | 15 | ## Various settings 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | xcuserdata/ 25 | 26 | ## Other 27 | *.moved-aside 28 | *.xccheckout 29 | *.xcscmblueprint 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | # Package.pins 46 | # Package.resolved 47 | .build/ 48 | 49 | # CocoaPods 50 | # 51 | # We recommend against adding the Pods directory to your .gitignore. However 52 | # you should judge for yourself, the pros and cons are mentioned at: 53 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 54 | # 55 | # Pods/ 56 | 57 | # Carthage 58 | # 59 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 60 | # Carthage/Checkouts 61 | 62 | Carthage/Build 63 | 64 | # fastlane 65 | # 66 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 67 | # screenshots whenever they are needed. 68 | # For more information about the recommended setup visit: 69 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 70 | 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots/**/*.png 74 | fastlane/test_output 75 | 76 | 77 | # Xcode 78 | # 79 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 80 | 81 | ## User settings 82 | xcuserdata/ 83 | 84 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 85 | *.xcscmblueprint 86 | *.xccheckout 87 | 88 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 89 | build/ 90 | DerivedData/ 91 | *.moved-aside 92 | *.pbxuser 93 | !default.pbxuser 94 | *.mode1v3 95 | !default.mode1v3 96 | *.mode2v3 97 | !default.mode2v3 98 | *.perspectivev3 99 | !default.perspectivev3 100 | 101 | ## Obj-C/Swift specific 102 | *.hmap 103 | 104 | ## App packaging 105 | *.ipa 106 | *.dSYM.zip 107 | *.dSYM 108 | 109 | ## Playgrounds 110 | timeline.xctimeline 111 | playground.xcworkspace 112 | 113 | # Swift Package Manager 114 | # 115 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 116 | # Packages/ 117 | # Package.pins 118 | # Package.resolved 119 | # *.xcodeproj 120 | # 121 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 122 | # hence it is not needed unless you have added a package configuration file to your project 123 | # .swiftpm 124 | 125 | .build/ 126 | 127 | # CocoaPods 128 | # 129 | # We recommend against adding the Pods directory to your .gitignore. However 130 | # you should judge for yourself, the pros and cons are mentioned at: 131 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 132 | # 133 | # Pods/ 134 | # 135 | # Add this line if you want to avoid checking in source code from the Xcode workspace 136 | # *.xcworkspace 137 | 138 | # Carthage 139 | # 140 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 141 | # Carthage/Checkouts 142 | 143 | Carthage/Build/ 144 | 145 | # Accio dependency management 146 | Dependencies/ 147 | .accio/ 148 | 149 | # fastlane 150 | # 151 | # It is recommended to not store the screenshots in the git repo. 152 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 153 | # For more information about the recommended setup visit: 154 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 155 | 156 | fastlane/report.xml 157 | fastlane/Preview.html 158 | fastlane/screenshots/**/*.png 159 | fastlane/test_output 160 | 161 | # Code Injection 162 | # 163 | # After new code Injection tools there's a generated folder /iOSInjectionProject 164 | # https://github.com/johnno1962/injectionforxcode 165 | 166 | iOSInjectionProject/ 167 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/ViewController/SuggestionTableCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutocompleteTableCellView.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | class SuggestionTableCellView: NSTableCellView { 11 | /// The cells title. 12 | var title: String = "" 13 | 14 | /// The cells subtitle. 15 | var subtitle: String = "" 16 | 17 | /// The cells imageView. 18 | var image: NSImage? { 19 | get { self.imageView?.image } 20 | set { self.imageView?.image = newValue } 21 | } 22 | 23 | /// The parts of the title to highlight. 24 | var highlightedTitleRanges: [Range] = [] 25 | 26 | /// The parts of the subtitle to highlight. 27 | var highlightedSubtitleRanges: [Range] = [] 28 | 29 | /// A reference to the enclosing row view. 30 | var isHighlighted: Bool = false { 31 | didSet { self.update() } 32 | } 33 | 34 | // MARK: - Constructor 35 | 36 | private func setup() { 37 | // Add the textField 38 | let textField = NSTextField(frame: .zero) 39 | textField.isBezeled = false 40 | textField.drawsBackground = false 41 | textField.isEditable = false 42 | textField.isSelectable = false 43 | textField.maximumNumberOfLines = 2 44 | self.textField = textField 45 | 46 | // Add the imageViw 47 | let imageView = NSImageView() 48 | self.imageView = imageView 49 | 50 | self.addSubview(imageView) 51 | self.addSubview(textField) 52 | } 53 | 54 | override init(frame frameRect: NSRect) { 55 | super.init(frame: frameRect) 56 | self.setup() 57 | } 58 | 59 | required init?(coder: NSCoder) { 60 | super.init(coder: coder) 61 | self.setup() 62 | } 63 | 64 | // MARK: - Reuse 65 | 66 | override func prepareForReuse() { 67 | super.prepareForReuse() 68 | self.image = nil 69 | self.isHighlighted = false 70 | } 71 | 72 | // MARK: - Layout 73 | 74 | override func layout() { 75 | super.layout() 76 | self.update() 77 | } 78 | 79 | /// Update the title and subtitle text and color. 80 | func update() { 81 | // Create a concatenated string with title and subtitle. 82 | let str = self.title + (self.subtitle.isEmpty ? "" : ("\n" + self.subtitle)) 83 | let mutableAttriStr = NSMutableAttributedString(string: str) 84 | 85 | // The range of the title and subtitle string. 86 | let titleRange = NSRange(location: 0, length: self.title.count) 87 | let subtitleRange = NSRange(location: self.title.count + 1, length: self.subtitle.count) 88 | // The paragraph style to use. 89 | let paragraphStyle = NSMutableParagraphStyle() 90 | paragraphStyle.lineBreakMode = .byTruncatingTail 91 | // The text color to use. 92 | let titleFontSize = NSFont.systemFontSize 93 | let titleColor: NSColor = self.isHighlighted ? .white : .labelColor 94 | let subtitleFontSize = NSFont.labelFontSize 95 | let subtitleColor: NSColor = self.isHighlighted ? .white : .secondaryLabelColor 96 | 97 | // Update the attributes string. 98 | mutableAttriStr.addAttributes([.paragraphStyle: paragraphStyle, 99 | .font: NSFont.systemFont(ofSize: titleFontSize), 100 | .foregroundColor: titleColor], range: titleRange) 101 | mutableAttriStr.addAttributes([.paragraphStyle: paragraphStyle, 102 | .font: NSFont.systemFont(ofSize: subtitleFontSize), 103 | .foregroundColor: subtitleColor], range: subtitleRange) 104 | 105 | // Update the title and subtitle highlight. 106 | self.highlightedTitleRanges.forEach { 107 | let range = NSMakeRange($0.startIndex, $0.count) 108 | mutableAttriStr.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: titleFontSize), range: range) 109 | } 110 | self.highlightedSubtitleRanges.forEach { 111 | let range = NSMakeRange(subtitleRange.location + $0.startIndex, $0.count) 112 | mutableAttriStr.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: subtitleFontSize), range: range) 113 | } 114 | 115 | // Layout the subviews. 116 | let pad: CGFloat = 3 117 | var remainingWidth = self.frame.width 118 | var frame: CGRect = .zero 119 | 120 | // If the imageView needs to be visible. 121 | if self.image != nil { 122 | let size = self.frame.height - pad*2 123 | frame = CGRect(x: pad, y: pad, width: size, height: size) 124 | remainingWidth -= frame.maxX 125 | self.imageView?.frame = frame 126 | } 127 | 128 | // Center the textField vertically inside the cell 129 | let textHeight = mutableAttriStr.size().height 130 | frame = CGRect(x: 0, y: 0, width: remainingWidth, height: textHeight) 131 | frame.origin.y = (self.frame.height-textHeight)/2.0 132 | frame.origin.x = self.frame.size.width - remainingWidth + pad 133 | self.textField?.frame = frame 134 | // Update the string. 135 | self.textField?.attributedStringValue = mutableAttriStr 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/ViewController/SuggestionListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutocompleteContentViewController.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | let kMaxResults = 5 11 | let kCellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "AutocompleteCell") 12 | 13 | class SuggestionListViewController: NSViewController, KeyResponder { 14 | /// The main tableView with all search results. 15 | var contentView: SuggestionListView! 16 | /// The list with all suggestions. 17 | var suggestions: [Suggestion] = [] 18 | /// The progress spinner when loading results. 19 | private var spinner: NSProgressIndicator! 20 | /// The target and action to perform when a cell is selected. 21 | var target: AnyObject? 22 | var action: Selector? 23 | 24 | /// Override loadView to load our custom content view. 25 | override func loadView() { 26 | // Create a container view that contains the effect view and the content view. 27 | let containerView = NSView() 28 | 29 | // Add the effect view. 30 | let effectView = NSVisualEffectView(frame: containerView.bounds) 31 | effectView.autoresizingMask = [.height, .width] 32 | effectView.isEmphasized = true 33 | effectView.state = .active 34 | if #available(OSX 11.0, *) { 35 | effectView.material = .hudWindow 36 | } else if #available(OSX 10.14, *) { 37 | effectView.material = .underWindowBackground 38 | } else { 39 | effectView.material = .titlebar 40 | } 41 | effectView.blendingMode = .behindWindow 42 | containerView.addSubview(effectView) 43 | 44 | // Add the content view. 45 | self.contentView = SuggestionListView(frame: containerView.bounds) 46 | self.contentView.autoresizingMask = [.height, .width] 47 | self.contentView.isHidden = false 48 | self.contentView.tableView.dataSource = self 49 | self.contentView.tableView.delegate = self 50 | containerView.addSubview(contentView) 51 | 52 | // Handle the tableView click events. 53 | self.contentView.tableView.target = self 54 | self.contentView.tableView.action = #selector(performActionForSelectedCell(_:)) 55 | 56 | // Add the progress spinner. 57 | self.spinner = NSProgressIndicator(frame: .zero) 58 | self.spinner.style = .spinning 59 | self.spinner.isHidden = true 60 | containerView.addSubview(self.spinner) 61 | 62 | // Apply a corner radius to the view. 63 | containerView.wantsLayer = true 64 | containerView.layer?.cornerRadius = 5.0 65 | 66 | self.view = containerView 67 | } 68 | 69 | override func viewDidLayout() { 70 | super.viewDidLayout() 71 | // Update the spinner. Autoresizing is not powerfull enough. 72 | let pad: CGFloat = 8.0 73 | let size = self.view.bounds.height - pad*2 74 | self.spinner.frame = CGRect(x: pad, y: pad, width: size, height: size) 75 | } 76 | 77 | // MARK: - Spinner 78 | 79 | func showSpinner() { 80 | self.spinner.startAnimation(nil) 81 | self.spinner.isHidden = false 82 | self.contentView.isHidden = true 83 | } 84 | 85 | func hideSpinner() { 86 | self.spinner.stopAnimation(nil) 87 | self.spinner.isHidden = true 88 | self.contentView.isHidden = false 89 | } 90 | 91 | // MARK: - Results 92 | 93 | func setSuggestions(_ suggestions: [Suggestion]) { 94 | self.suggestions = suggestions 95 | self.contentView.tableView.reloadData() 96 | } 97 | 98 | // MARK: - Helper 99 | 100 | /// Get the current suggested content size. 101 | func getSuggestedWindowSize() -> CGSize { 102 | guard let tableView = self.contentView.tableView else { return .zero } 103 | 104 | let numberOfRows = min(tableView.numberOfRows, kMaxResults) 105 | let rowHeight = tableView.rowHeight 106 | let spacing = tableView.intercellSpacing 107 | var frame = self.view.frame 108 | frame.size.height = (rowHeight + spacing.height) * CGFloat(numberOfRows) 109 | return frame.size 110 | } 111 | 112 | // MARK: - Key events 113 | 114 | func processKeys(with theEvent: NSEvent) -> NSEvent? { 115 | let keyUp: Bool = theEvent.type == .keyUp 116 | 117 | if let keyEvent = KeyCodes(rawValue: theEvent.keyCode) { 118 | switch keyEvent { 119 | case .arrowUp: 120 | if !keyUp { 121 | self.contentView.selectPreviousRow() 122 | } 123 | // Capture this event. 124 | return nil 125 | case .arrowDown: 126 | if !keyUp { 127 | self.contentView.selectNextRow() 128 | } 129 | // Capture this event. 130 | return nil 131 | case .return: 132 | // Perform the action for the currently selected cell. 133 | self.performActionForSelectedCell() 134 | return nil 135 | default: 136 | break 137 | } 138 | } 139 | 140 | return theEvent 141 | } 142 | 143 | // MARK: - Click 144 | 145 | @objc func performActionForSelectedCell(_ sender: AnyObject? = nil) { 146 | let selectedRow = self.contentView.tableView.selectedRow 147 | if selectedRow >= 0 && selectedRow < self.suggestions.count { 148 | let suggestion = self.suggestions[selectedRow] 149 | _ = self.target?.perform(self.action, with: suggestion) 150 | } 151 | } 152 | } 153 | 154 | 155 | extension SuggestionListViewController: NSTableViewDataSource { 156 | func numberOfRows(in tableView: NSTableView) -> Int { 157 | return self.suggestions.count 158 | } 159 | } 160 | 161 | extension SuggestionListViewController: NSTableViewDelegate { 162 | func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { 163 | return SuggestionTableRowView(tableView: tableView, row: row) 164 | } 165 | 166 | func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { 167 | var cellView = tableView.makeView(withIdentifier: kCellIdentifier, owner: self) as? SuggestionTableCellView 168 | if cellView == nil { 169 | let cellFrame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: tableView.rowHeight) 170 | cellView = SuggestionTableCellView(frame: cellFrame) 171 | cellView?.identifier = kCellIdentifier 172 | } 173 | // Assign the new values and update the cell. 174 | cellView?.image = self.suggestions[row].image 175 | cellView?.title = self.suggestions[row].title 176 | cellView?.subtitle = self.suggestions[row].subtitle 177 | cellView?.highlightedTitleRanges = self.suggestions[row].highlightedTitleRanges 178 | cellView?.highlightedSubtitleRanges = self.suggestions[row].highlightedSubtitleRanges 179 | cellView?.update() 180 | 181 | return cellView 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Sources/SuggestionPopup/WindowController/SuggestionWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutoCompleteWindowController.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | import AppKit 8 | 9 | // TODO: On textField becomes first responder, show the popup. 10 | 11 | class SuggestionWindowController: NSWindowController, KeyResponder { 12 | /// The minimum width of the window. 13 | internal var minimumWindowWidth: CGFloat = 0 14 | 15 | /// The textfield instance to manage. 16 | private weak var searchField: NSTextField! 17 | 18 | /// A referenc to the searchFields parent window. 19 | private var parentWindow: NSWindow? { return self.searchField.window } 20 | 21 | /// The currently entered search query. 22 | private var searchQuery: String = "" 23 | 24 | /// A reference to the textDidChange observer. 25 | private var textDidChangeObserver: NSObjectProtocol? 26 | 27 | /// A reference to the tableView selection change observer. 28 | private var selecionChangedObserver: NSObjectProtocol? 29 | 30 | /// Callback handlers. 31 | var onShow: SuggestionShowAction? 32 | var onHide: SuggestionHideAction? 33 | var onHighlight: SuggestionHighlightAction? 34 | var onSelect: SuggestionSelectAction? 35 | 36 | // MARK: - Constructor 37 | 38 | init(searchField: NSTextField) { 39 | self.searchField = searchField 40 | let window = SuggestionWindow() 41 | window.hidesOnDeactivate = false 42 | 43 | super.init(window: window) 44 | 45 | // Handle the cell selection. 46 | let contentViewController = self.contentViewController as? SuggestionListViewController 47 | contentViewController?.target = self 48 | contentViewController?.action = #selector(self.selectedSuggestion(_:)) 49 | 50 | // Listen for text changes inside the textField. 51 | self.registerTextDidChangeNotifications() 52 | } 53 | 54 | required init?(coder: NSCoder) { 55 | fatalError("InitWithCoder not supported.") 56 | } 57 | 58 | 59 | // MARK: - Destructor 60 | 61 | deinit { 62 | if let observer = self.textDidChangeObserver { 63 | NotificationCenter.default.removeObserver(observer) 64 | } 65 | self.textDidChangeObserver = nil 66 | } 67 | 68 | // MARK: - TextField 69 | 70 | private func registerTextDidChangeNotifications() { 71 | let center = NotificationCenter.default 72 | self.textDidChangeObserver = center.addObserver(forName: NSTextField.textDidChangeNotification, 73 | object: self.searchField, 74 | queue: .main) { [weak self] _ in 75 | // Save the current search query. 76 | self?.searchQuery = self?.searchField.stringValue ?? "" 77 | } 78 | } 79 | 80 | func registerCellSelectionNotification() { 81 | // Listen for tableView cell highlighting. 82 | guard let contentViewController = self.contentViewController as? SuggestionListViewController else { 83 | return 84 | } 85 | let center = NotificationCenter.default 86 | let tableView = contentViewController.contentView.tableView 87 | self.selecionChangedObserver = center.addObserver(forName: NSTableView.selectionDidChangeNotification, 88 | object: tableView, 89 | queue: .main) { [weak self] notification in 90 | guard let row = tableView?.selectedRow, let queryString = self?.searchQuery else { return } 91 | 92 | let editor = self?.searchField.currentEditor() as? NSTextView 93 | var suggestion: Suggestion? 94 | if row >= 0 { 95 | // Cell selected, display the suggestion. 96 | suggestion = contentViewController.suggestions[row] 97 | let title = suggestion!.title 98 | 99 | // If the search string matches the start of the title, highlight the remaining part, 100 | // otherwise highlight the complete title. 101 | self?.searchField.stringValue = title 102 | let range = NSMakeRange(title.starts(with: queryString) ? queryString.count : 0, title.count) 103 | editor?.setSelectedRange(range) 104 | 105 | } else { 106 | // Cell was deselected. Reset the search query and clear the seletion. 107 | self?.searchField.stringValue = queryString 108 | editor?.moveToEndOfLine(nil) 109 | } 110 | self?.onHighlight?(queryString, suggestion) 111 | } 112 | } 113 | 114 | func unregisterCellSelectionNotification() { 115 | if let observer = self.selecionChangedObserver { 116 | NotificationCenter.default.removeObserver(observer) 117 | } 118 | self.selecionChangedObserver = nil 119 | } 120 | 121 | // MARK: - Selection 122 | 123 | @objc private func selectedSuggestion(_ suggestion: AnyObject) { 124 | guard let suggestion = suggestion as? Suggestion else { return } 125 | self.onSelect?(self.searchQuery, suggestion) 126 | // Update the searchField text, after the window is dismissed. That way the the cell seletion callback 127 | // won't fire. 128 | self.searchField.stringValue = suggestion.title 129 | } 130 | 131 | // MARK: - Show / Hide 132 | 133 | /// Show the window. 134 | /// - Return: True if the window can be shown, false otherwise. 135 | @discardableResult 136 | func show() -> Bool { 137 | // Make sure the searchField is inside the view hierachy. 138 | guard let window = self.window, !window.isVisible, 139 | let parentWindow = self.parentWindow, 140 | let searchFieldParent = self.searchField.superview else { return false } 141 | // The window has the same width as the searchField. 142 | var frame = window.frame 143 | frame.size.width = max(self.searchField.frame.width, self.minimumWindowWidth) 144 | // Position the window directly below the searchField. 145 | var location = searchFieldParent.convert(self.searchField.frame.origin, to: nil) 146 | location = parentWindow.convertToScreen(CGRect(x: location.x, y: location.y, width: 0, height: 0)).origin 147 | location.y -= 5 148 | // Apply the frame and position. 149 | window.setContentSize(frame.size) 150 | window.setFrameTopLeftPoint(location) 151 | // Show the window 152 | parentWindow.addChildWindow(window, ordered: .above) 153 | // Liste for cell selection events. 154 | self.registerCellSelectionNotification() 155 | // Perform the callback. 156 | self.onShow?() 157 | return true 158 | } 159 | 160 | /// Hide the window. 161 | @discardableResult 162 | func hide() -> Bool { 163 | guard let window = self.window, window.isVisible else { return false } 164 | window.parent?.removeChildWindow(window) 165 | window.orderOut(nil) 166 | // Stop listening for cell selection. 167 | self.unregisterCellSelectionNotification() 168 | // Perform the callback. 169 | self.onHide?() 170 | return true 171 | } 172 | 173 | // MARK: - Results 174 | 175 | func setSuggestions(_ suggestions: [Suggestion]) { 176 | guard let window = self.window as? SuggestionWindow else { return } 177 | window.setSuggestions(suggestions) 178 | } 179 | 180 | // MARK: - Key Events 181 | 182 | /// Return the event, to allow other classes to handle the event or nil to capture it. 183 | func processKeys(with theEvent: NSEvent) -> NSEvent? { 184 | // Check if the window's contentViewController can handle the event. 185 | let viewController = self.contentViewController as? KeyResponder 186 | return viewController != nil ? viewController?.processKeys(with: theEvent) : theEvent 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /Sources/SuggestionPopup/SearchCompleter/SearchCompleter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AutocompleteSearchController.swift 3 | // Popup 4 | // 5 | // Created by David Klopp on 26.12.20. 6 | // 7 | 8 | import AppKit 9 | 10 | open class SearchCompleter: NSObject, KeyResponder { 11 | /// The main searchField instance. 12 | public weak var searchField: NSTextField! 13 | /// The minimum width of the window. 14 | public var minimumWindowWidth: CGFloat { 15 | get { return self.windowController?.minimumWindowWidth ?? 0 } 16 | set { self.windowController?.minimumWindowWidth = newValue } 17 | } 18 | /// Called when the searchField the first responder. 19 | public var onBecomeFirstReponder: SuggestionFirstResponderAction? 20 | /// Called when the searchField the first responder. 21 | public var onResignFirstReponder: SuggestionFirstResponderAction? 22 | /// Called when the window is shown. 23 | public var onShow: SuggestionShowAction? 24 | /// Called when the window is hidden. 25 | public var onHide: SuggestionHideAction? 26 | /// Called when a suggestion is selected. 27 | public var onSelect: SuggestionSelectAction? 28 | /// Called when a suggestion is highlighted, but not yet selected.. 29 | public var onHighlight: SuggestionHighlightAction? { 30 | get { return self.windowController.onHighlight } 31 | set { self.windowController.onHighlight = newValue } 32 | } 33 | 34 | /// The main window controller. 35 | var windowController: SuggestionWindowController! 36 | 37 | /// A reference to the textDidChange observer. 38 | private var textDidChangeObserver: NSObjectProtocol? 39 | 40 | /// A reference to the 41 | private var windowObserver: NSKeyValueObservation? 42 | 43 | /// A reference to the window didResignKey observer. 44 | private var lostFocusObserver: Any? 45 | 46 | /// The internal monitor to capture key events. 47 | private var localKeyEventMonitor: Any? 48 | 49 | /// The internal monitor to capture mouse events. 50 | private var localMouseDownEventMonitor: Any? 51 | 52 | /// Is the searchField currently the first responder. 53 | private var searchFieldIsFirstResponder: Bool = false 54 | 55 | // MARK: - Constructor 56 | 57 | public init(searchField: NSTextField) { 58 | super.init() 59 | self.searchField = searchField 60 | self.windowController = SuggestionWindowController(searchField: searchField) 61 | self.setup() 62 | 63 | // Listen for text changes inside the textField. 64 | self.registerTextFieldNotifications() 65 | self.registerFocusNotifications() 66 | 67 | // Add and remove the key and mouse events depending on whether the window is visible. 68 | self.windowController.onShow = { [weak self] in 69 | self?.registerKeyEvents() 70 | self?.registerMouseEvents() 71 | self?.onShow?() 72 | } 73 | self.windowController.onHide = { [weak self] in 74 | self?.unregisterKeyEvents() 75 | self?.unregisterMouseEvents() 76 | self?.onHide?() 77 | } 78 | self.windowController.onSelect = { [weak self] in 79 | self?.windowController.hide() 80 | self?.onSelect?($0, $1) 81 | } 82 | } 83 | 84 | // MARK: - Destructor 85 | 86 | deinit { 87 | self.unregisterTextFieldNotifications() 88 | self.unregisterFocusNotifications() 89 | } 90 | 91 | // MARK: - KeyEvents 92 | 93 | /// Handle key up and down events. 94 | private func registerKeyEvents() { 95 | self.localKeyEventMonitor = NSEvent.addLocalMonitorForEvents( 96 | matching: [.keyDown, .keyUp]) { [weak self] (event) -> NSEvent? in 97 | // If the current searchField is the first responder, we capture the event. 98 | guard let firstResponder = self?.searchField?.window?.firstResponder else { return event } 99 | if firstResponder == self?.searchField.currentEditor() { 100 | return self?.processKeys(with: event) 101 | } 102 | return event 103 | } 104 | } 105 | 106 | /// Remove the key event monitor. 107 | private func unregisterKeyEvents() { 108 | if let eventMonitor = self.localKeyEventMonitor { 109 | NSEvent.removeMonitor(eventMonitor) 110 | } 111 | self.localKeyEventMonitor = nil 112 | } 113 | 114 | /// Return the event, to allow other classes to handle the event or nil to capture it. 115 | func processKeys(with theEvent: NSEvent) -> NSEvent? { 116 | // Check if this controller can handle the event. 117 | if let keyEvent = KeyCodes(rawValue: theEvent.keyCode) { 118 | switch keyEvent { 119 | case .return: 120 | // Hide the window. 121 | self.windowController.hide() 122 | case .tab: 123 | // Do not capture the tab event. We still want to be able to change the focus. 124 | self.windowController.hide() 125 | default: 126 | break 127 | } 128 | } 129 | // Check if the window controller can handle the event. 130 | return self.windowController != nil ? self.windowController?.processKeys(with: theEvent) : theEvent 131 | } 132 | 133 | // MARK: - Mouse Events 134 | 135 | /// Handle mouse clickes inside and outside the window. 136 | private func registerMouseEvents() { 137 | self.localMouseDownEventMonitor = NSEvent.addLocalMonitorForEvents( 138 | matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] (event) -> NSEvent? in 139 | // Make sure the event has a window. 140 | guard let eventWindow = event.window, let window = self?.windowController.window else { return event } 141 | let isSuggestionWindow = eventWindow == window 142 | let clickedInsideContentView = eventWindow.contentView?.hitTest(event.locationInWindow) != nil 143 | let clickedInsideTextField = self?.searchField.hitTest(event.locationInWindow) != nil 144 | 145 | // If the event window was clicked outside its toolbar then dismiss the popup. 146 | if !isSuggestionWindow && clickedInsideContentView && !clickedInsideTextField { 147 | self?.windowController.hide() 148 | } 149 | return event 150 | } 151 | } 152 | 153 | /// Remove the mouse click monitor. 154 | private func unregisterMouseEvents() { 155 | if let eventMonitor = self.localMouseDownEventMonitor { 156 | NSEvent.removeMonitor(eventMonitor) 157 | } 158 | self.localMouseDownEventMonitor = nil 159 | } 160 | 161 | // MARK: - TextField 162 | 163 | private func registerTextFieldNotifications() { 164 | self.textDidChangeObserver = NotificationCenter.default.addObserver( 165 | forName: NSTextField.textDidChangeNotification, 166 | object: self.searchField, queue: .main) { [weak self] _ in 167 | 168 | let text = self?.searchField.stringValue ?? "" 169 | if text.isEmpty { 170 | // Hide window 171 | self?.windowController.hide() 172 | } else { 173 | self?.prepareSuggestions(for: text) 174 | // Show the autocomplete window. 175 | self?.windowController.show() 176 | } 177 | } 178 | 179 | self.windowObserver = self.searchField.observe(\.window?.firstResponder) { [weak self] searchField, _ in 180 | // KVO is strange... this called every time the first responder changes, but old and new is always nil. 181 | let firstResponder = searchField.window?.firstResponder 182 | guard searchField.currentEditor() == firstResponder else { 183 | if self?.searchFieldIsFirstResponder ?? false { 184 | self?.onResignFirstReponder?() 185 | self?.searchFieldIsFirstResponder = false 186 | } 187 | return 188 | } 189 | // Show the window if the searchField contains any text. 190 | let text = searchField.stringValue 191 | if !text.isEmpty { 192 | //self?.prepareSuggestions(for: text) 193 | self?.windowController.show() 194 | } 195 | self?.searchFieldIsFirstResponder = true 196 | self?.onBecomeFirstReponder?() 197 | } 198 | } 199 | 200 | private func unregisterTextFieldNotifications() { 201 | if let observer = self.textDidChangeObserver { 202 | NotificationCenter.default.removeObserver(observer) 203 | } 204 | self.textDidChangeObserver = nil 205 | 206 | self.windowObserver?.invalidate() 207 | self.windowObserver = nil 208 | } 209 | 210 | // MARK: - Focus 211 | 212 | private func registerFocusNotifications() { 213 | // If the suggestion window looses focus we dismiss it. 214 | guard let window = self.windowController.window else { return } 215 | self.lostFocusObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, 216 | object: window, 217 | queue: nil) { [weak self] _ in 218 | self?.windowController.hide() 219 | } 220 | } 221 | 222 | private func unregisterFocusNotifications() { 223 | if let observer = self.lostFocusObserver { 224 | NotificationCenter.default.removeObserver(observer) 225 | } 226 | self.lostFocusObserver = nil 227 | } 228 | 229 | // MARK: - Public Methods 230 | 231 | /// Show the spinner to indicate work. 232 | public func showSpinner() { 233 | let window = self.windowController.window as? SuggestionWindow 234 | window?.showSpinner() 235 | } 236 | 237 | /// Hide the spinner to indicate the work is finished. 238 | public func hideSpinner() { 239 | let window = self.windowController.window as? SuggestionWindow 240 | window?.hideSpinner() 241 | } 242 | 243 | // MARK: - Override 244 | 245 | /// Override this function to perform initial setup. 246 | open func setup() { 247 | 248 | } 249 | 250 | /// This function is called when the textField text changes. Prepare your search results here. 251 | open func prepareSuggestions(for searchString: String) { 252 | 253 | } 254 | 255 | /// Use this function to update the search results. 256 | open func setSuggestions(_ suggestions: [Suggestion]) { 257 | self.windowController.setSuggestions(suggestions) 258 | // If we don't have any suggestions hide the window. 259 | if suggestions.isEmpty { 260 | self.windowController.hide() 261 | } 262 | } 263 | } 264 | --------------------------------------------------------------------------------