Created
November 5, 2018 13:41
-
-
Save martinpi/5e5ca6f0df035145402bf2f288055dfd to your computer and use it in GitHub Desktop.
An autocomplete popup for text entry. Ported to Swift from this example: https://github.com/danjonweb/NCRAutocompleteTextView
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// AutocompleteView.swift | |
// | |
// Originally created by Daniel Weber on 9/28/14. | |
// Copyright (c) 2014 Null Creature. All rights reserved. | |
// Ported to Swift by Martin Pichlmair 2018 | |
// | |
import Cocoa | |
let MAX_RESULTS = 10 | |
let HIGHLIGHT_STROKE_COLOR = NSColor.selectedMenuItemColor | |
let HIGHLIGHT_FILL_COLOR = NSColor.selectedMenuItemColor | |
let HIGHLIGHT_RADIUS = 0.0 | |
let INTERCELL_SPACING = NSMakeSize(20.0, 3.0) | |
let WORD_BOUNDARY_STRING="().#abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" | |
let POPOVER_WIDTH = 250.0 | |
let POPOVER_PADDING = 0.0 | |
let POPOVER_APPEARANCE = NSAppearance.Name.vibrantLight | |
let POPOVER_FONT = NSFont.init(name: "Menlo", size: 12) | |
// The font for the characters that have already been typed | |
let POPOVER_BOLDFONT = NSFont.init(name: "Menlo-Bold", size: 12) | |
let POPOVER_TEXTCOLOR = NSColor.black | |
protocol AutocompleteTableViewDelegate { | |
func textView(_ textView: NSTextView, completions words: [String], forPartialWordRange: NSRange, indexOfSelectedItem: UnsafeMutablePointer<Int>?) -> [String] | |
func textView(_ textView: NSTextView, imageForCompletion word: String) -> NSImage? | |
} | |
class AutocompleteTableRowView : NSTableRowView { | |
override func drawSelection(in dirtyRect: NSRect) { | |
if selectionHighlightStyle != .none { | |
let selectionRect: NSRect = NSInsetRect(bounds, 0.5, 0.5) | |
HIGHLIGHT_STROKE_COLOR.setStroke() | |
HIGHLIGHT_FILL_COLOR.setFill() | |
let selectionPath: NSBezierPath = NSBezierPath(roundedRect: selectionRect, xRadius: CGFloat(HIGHLIGHT_RADIUS), yRadius: CGFloat(HIGHLIGHT_RADIUS)) | |
selectionPath.fill() | |
selectionPath.stroke() | |
} | |
} | |
override var interiorBackgroundStyle: NSView.BackgroundStyle { get { | |
if (isSelected) { | |
return NSView.BackgroundStyle.dark | |
} else { | |
return NSView.BackgroundStyle.light | |
} | |
} | |
} | |
} | |
// MARK: - | |
class AutocompleteTextView : NSTextView, NSTableViewDelegate, NSTableViewDataSource { | |
var autocompletePopover: NSPopover? | |
weak var autocompleteTableView: NSTableView? | |
var matches: [String]? = [String]() | |
var substring: String? | |
var lastPos: Int? | |
override func awakeFromNib() { | |
// Make a table view with 1 column and enclosing scroll view. It doesn't | |
// matter what the frames are here because they are set when the popover | |
// is displayed | |
let column1: NSTableColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "text")) | |
column1.isEditable = false | |
column1.width = CGFloat(POPOVER_WIDTH-2*POPOVER_PADDING) | |
let tableView: NSTableView = NSTableView(frame: NSZeroRect) | |
tableView.selectionHighlightStyle = .regular | |
tableView.backgroundColor = NSColor.clear | |
tableView.rowSizeStyle = .small | |
tableView.intercellSpacing = INTERCELL_SPACING | |
tableView.headerView = nil | |
tableView.refusesFirstResponder = true | |
tableView.target = self | |
tableView.doubleAction = #selector(NSArrayController.insert(_:)) | |
tableView.addTableColumn(column1) | |
tableView.delegate = self | |
tableView.dataSource = self | |
autocompleteTableView = tableView | |
let tableScrollView: NSScrollView = NSScrollView(frame: NSZeroRect) | |
tableScrollView.drawsBackground = false | |
tableScrollView.documentView = tableView | |
tableScrollView.hasVerticalScroller = true | |
let contentView: NSView = NSView(frame: NSZeroRect) | |
contentView.addSubview(tableScrollView) | |
let contentViewController: NSViewController = NSViewController() | |
contentViewController.view = contentView | |
autocompletePopover = NSPopover() | |
autocompletePopover!.appearance = NSAppearance(named: POPOVER_APPEARANCE) | |
autocompletePopover!.animates = false | |
autocompletePopover!.contentViewController = contentViewController | |
matches = [String]() | |
lastPos = -1 | |
NotificationCenter.default.addObserver(self, selector: #selector(didChangeSelection(_:)), name: NSNotification.Name(rawValue: "NSTextViewDidChangeSelectionNotification"), object: nil) | |
} | |
override func keyDown(with theEvent: NSEvent) { | |
let row: Int = autocompleteTableView!.selectedRow | |
var shouldComplete: Bool = true | |
switch theEvent.keyCode { | |
case 51: | |
// Delete | |
autocompletePopover!.close() | |
shouldComplete = false | |
break | |
case 53: | |
// Esc | |
if autocompletePopover!.isShown { | |
autocompletePopover!.close() | |
} | |
return | |
// Skip default behavior | |
case 125: | |
// Down | |
if autocompletePopover!.isShown { | |
autocompleteTableView!.selectRowIndexes(NSIndexSet(index: row+1) as IndexSet, byExtendingSelection: false) | |
autocompleteTableView!.scrollRowToVisible(autocompleteTableView!.selectedRow) | |
return | |
// Skip default behavior | |
} | |
break | |
case 126: | |
// Up | |
if autocompletePopover!.isShown { | |
autocompleteTableView!.selectRowIndexes(NSIndexSet(index: row-1) as IndexSet, byExtendingSelection: false) | |
autocompleteTableView!.scrollRowToVisible(autocompleteTableView!.selectedRow) | |
return | |
// Skip default behavior | |
} | |
break | |
case 49: | |
// Space | |
if autocompletePopover!.isShown { | |
autocompletePopover!.close() | |
} | |
break | |
default: | |
// Return or tab | |
if autocompletePopover!.isShown && ( | |
theEvent.keyCode == 0x24 || theEvent.keyCode == 0x4C || theEvent.keyCode == 0x30) { | |
insert(self) | |
return | |
// Skip default behavior | |
} | |
} | |
super.keyDown(with: theEvent) | |
if shouldComplete && theEvent.characters != nil && WORD_BOUNDARY_STRING.contains(theEvent.characters![(theEvent.characters?.startIndex)!]) { | |
complete(self) | |
} | |
} | |
func insert(_ sender: Any) { | |
if autocompleteTableView!.selectedRow >= 0 && autocompleteTableView!.selectedRow < matches!.count { | |
let string: String = (matches![autocompleteTableView!.selectedRow]) | |
let beginningOfWord: Int = selectedRange.location-substring!.count | |
let range: NSRange = NSMakeRange(beginningOfWord, substring!.count) | |
if shouldChangeText(in: range, replacementString: string) { | |
replaceCharacters(in: range, with: string) | |
didChangeText() | |
} | |
} | |
autocompletePopover!.close() | |
} | |
@objc func didChangeSelection(_ notification: NSNotification) { | |
if labs(selectedRange.location-lastPos!) > 1 { | |
// If selection moves by more than just one character, hide autocomplete | |
autocompletePopover!.close() | |
} | |
} | |
func complete(_ sender: Any) { | |
let str = string as NSString | |
var wordStart = selectedRange.location - 1 | |
for i in stride(from: selectedRange.location - 1, to: 0, by: -1) { | |
let char = Character(UnicodeScalar(str.character(at: i)) ?? " ") | |
if !WORD_BOUNDARY_STRING.contains(char) { | |
wordStart = i + 1 | |
break | |
} | |
} | |
var wordLength = 0 | |
for i in wordStart ... str.length { | |
let char = Character(UnicodeScalar(str.character(at: i)) ?? " ") | |
if !WORD_BOUNDARY_STRING.contains(char) { | |
wordLength = i - wordStart | |
break | |
} | |
} | |
let substringRange = NSMakeRange(wordStart, wordLength) | |
substring = str.substring(with: substringRange) | |
if selectedRange.location == wordStart || wordLength == 0 { | |
// This happens when we just started a new word or if we have already typed the entire word | |
autocompletePopover!.close() | |
return | |
} | |
let index: Int = 0 | |
matches = completions(range:substringRange) | |
if matches!.count > 0 { | |
lastPos = selectedRange.location | |
autocompleteTableView!.reloadData() | |
autocompleteTableView!.selectRowIndexes(NSIndexSet(index: index) as IndexSet, byExtendingSelection: false) | |
autocompleteTableView!.scrollRowToVisible(index) | |
// Make the frame for the popover. We want it to shrink with a small number | |
// of items to autocomplete but never grow above a certain limit when there | |
// are a lot of items. The limit is set by MAX_RESULTS. | |
let numberOfRows: Int = min(autocompleteTableView!.numberOfRows, MAX_RESULTS) | |
let height: CGFloat = (autocompleteTableView!.rowHeight+autocompleteTableView!.intercellSpacing.height)*CGFloat(numberOfRows)+2.0*CGFloat(POPOVER_PADDING) | |
let frame: NSRect = NSMakeRect(0, 0, CGFloat(POPOVER_WIDTH), height) | |
autocompleteTableView!.enclosingScrollView!.frame = NSInsetRect(frame, CGFloat(POPOVER_PADDING), CGFloat(POPOVER_PADDING)) | |
autocompletePopover!.contentSize = NSMakeSize(NSWidth(frame), NSHeight(frame)) | |
// We want to find the middle of the first character to show the popover. | |
// firstRectForCharacterRange: will give us the rect at the begeinning of | |
// the word, and then we need to find the half-width of the first character | |
// to add to it. | |
var rect: NSRect = firstRect(forCharacterRange: substringRange, actualRange: nil) | |
rect = window!.convertFromScreen(rect) | |
rect = convert(rect, from: nil) | |
let firstChar: String = String(substring![(substring?.startIndex)!..<(substring?.index((substring?.startIndex)!, offsetBy: 1))!]) | |
let firstCharSize: NSSize = firstChar.size(withAttributes: [NSAttributedString.Key.font: font!]) | |
rect.size.width = firstCharSize.width | |
autocompletePopover!.show(relativeTo: rect, of: self, preferredEdge: .maxY) | |
} else { | |
autocompletePopover!.close() | |
} | |
} | |
func completions(range:NSRange) -> [String] { | |
if ((delegate as? AutocompleteTableViewDelegate) != nil) { | |
var index: Int = 0 | |
return delegate!.textView!(self, completions: [], forPartialWordRange: range, indexOfSelectedItem: &index) | |
} | |
return [] | |
} | |
func numberOfRows(in tableView: NSTableView) -> Int { | |
return matches!.count | |
} | |
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { | |
var cellView = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "MyView"), owner: self) | |
if cellView == nil { | |
cellView = NSTableCellView(frame: NSZeroRect) | |
let textField: NSTextField = NSTextField(frame: NSZeroRect) | |
textField.isBezeled = false | |
textField.drawsBackground = false | |
textField.isEditable = false | |
textField.isSelectable = false | |
cellView!.addSubview(textField) | |
(cellView as! NSTableCellView).textField = textField | |
if delegate!.responds(to: Selector(("textView:imageForCompletion:"))) { | |
let imageView: NSImageView = NSImageView(frame: NSZeroRect) | |
imageView.imageFrameStyle = .none | |
imageView.imageScaling = NSImageScaling.scaleNone | |
cellView!.addSubview(imageView) | |
(cellView as! NSTableCellView).imageView = imageView | |
} | |
cellView!.identifier = NSUserInterfaceItemIdentifier(rawValue: "MyView") | |
} | |
let astring: NSMutableAttributedString = NSMutableAttributedString(string: matches![row] , attributes: [NSAttributedString.Key.font:POPOVER_FONT!, NSAttributedString.Key.foregroundColor:POPOVER_TEXTCOLOR]) | |
if (substring != nil) { | |
let range = astring.mutableString.range(of: substring!, options: NSString.CompareOptions(rawValue: NSString.CompareOptions.anchored.rawValue | NSString.CompareOptions.caseInsensitive.rawValue)) | |
astring.addAttribute(NSAttributedString.Key.font, value: POPOVER_BOLDFONT!, range: range) | |
} | |
(cellView as! NSTableCellView).textField!.attributedStringValue = astring | |
if delegate!.responds(to: Selector(("textView:imageForCompletion:"))) { | |
let image: NSImage = (delegate! as! AutocompleteTableViewDelegate).textView(self, imageForCompletion: matches![row] )! | |
(cellView as! NSTableCellView).imageView?.image = image | |
} | |
return cellView | |
} | |
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { | |
return AutocompleteTableRowView() | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment