Skip to content

Instantly share code, notes, and snippets.

@martinpi
Created November 5, 2018 13:41
Show Gist options
  • Save martinpi/5e5ca6f0df035145402bf2f288055dfd to your computer and use it in GitHub Desktop.
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
//
// 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