Skip to content

Commit

Permalink
Implement fuzzy search
Browse files Browse the repository at this point in the history
It's disabled by default and you can enable it by setting `fuzzySearch`
preference to true.

Closes #25.
  • Loading branch information
p0deje committed Sep 11, 2019
1 parent 0cf644a commit aaad717
Show file tree
Hide file tree
Showing 29 changed files with 1,479 additions and 324 deletions.
92 changes: 51 additions & 41 deletions Maccy.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

48 changes: 23 additions & 25 deletions Maccy/Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class Menu: NSMenu, NSMenuDelegate {
public var history: History?

private let menuWidth = 300
private let search = Search()

required init(coder decoder: NSCoder) {
super.init(coder: decoder)
Expand Down Expand Up @@ -47,12 +48,24 @@ class Menu: NSMenu, NSMenuDelegate {
}

func updateFilter(filter: String) {
self.items = allItems.filter { itemMatchesFilter($0, filter) }
setKeyEquivalents(items)
let oldAllItems = allItems

let searchItem = allItems.first
let results = search.search(string: filter, within: searchableItems())
let systemItems = allItems.filter({ isSystemItem(item: $0) })

allItems.removeAll()
items.removeAll()

items = [searchItem!]
items += results
items += [NSMenuItem.separator()]
items += systemItems

// do not highlight system items on search
let highlightable = highlightableItems(items).filter { !isSystemItem(item: $0) }.first
highlight(highlightable)
allItems = oldAllItems

setKeyEquivalents(items)
highlight(results.first)
}

func select() {
Expand Down Expand Up @@ -91,12 +104,8 @@ class Menu: NSMenu, NSMenuDelegate {
if let historyItemToRemove = itemToRemove as? HistoryMenuItem {
if let fullTitle = historyItemToRemove.fullTitle {
if let index = items.firstIndex(of: itemToRemove) {
print("before: \(items.map({ $0.title }))")
print("before: \(allItems.map({ $0.title }))")
removeItem(at: index) // alternate
removeItem(at: index - 1)
print("after: \(items.map({ $0.title }))")
print("after: \(allItems.map({ $0.title }))")
history?.remove(fullTitle)
setKeyEquivalents(items)
highlight(items[index])
Expand Down Expand Up @@ -136,21 +145,6 @@ class Menu: NSMenu, NSMenuDelegate {
}
}

private func itemMatchesFilter(_ item: NSMenuItem, _ filter: String) -> Bool {
if filter.isEmpty || !item.isEnabled || item.isSeparatorItem || isSystemItem(item: item) {
return true
}

let range = item.title.range(
of: filter,
options: .caseInsensitive,
range: nil,
locale: nil
)

return (range != nil)
}

private func setKeyEquivalents(_ items: [NSMenuItem]) {
let mainItems = items.filter { !$0.isAlternate && !isSystemItem(item: $0) }
let altItems = items.filter { $0.isAlternate }
Expand All @@ -176,8 +170,12 @@ class Menu: NSMenu, NSMenuDelegate {
}
}

private func searchableItems() -> [NSMenuItem] {
return allItems.filter({ $0.isEnabled && !$0.isSeparatorItem && !isSystemItem(item: $0) })
}

private func isSystemItem(item: NSMenuItem) -> Bool {
let items = self.allItems.split(whereSeparator: { $0.isSeparatorItem })
let items = allItems.split(whereSeparator: { $0.isSeparatorItem })
return items.count > 1 && items[1].contains(item)
}
}
43 changes: 43 additions & 0 deletions Maccy/Search.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import AppKit
import Fuse

class Search {
private let fuzzySearchPref = "fuzzySearch"
private let fuse = Fuse(threshold: 0.7) // threshold found by trial-and-error

init() {
UserDefaults.standard.register(defaults: [fuzzySearchPref: false])
}

func search(string: String, within: [NSMenuItem]) -> [NSMenuItem] {
guard !string.isEmpty else {
return within
}

if UserDefaults.standard.bool(forKey: fuzzySearchPref) {
return fuzzySearch(string: string, within: within)
} else {
return simpleSearch(string: string, within: within)
}
}

private func fuzzySearch(string: String, within: [NSMenuItem]) -> [NSMenuItem] {
let searchResults = within.map({ (score: fuse.search(string, in: $0.title)?.score, object: $0) })
let matchedResults = searchResults.filter({ $0.score != nil })
let sortedResults = matchedResults.sorted(by: { ($0.score ?? 0) < ($1.score ?? 0) })
return sortedResults.map({ $0.object })
}

private func simpleSearch(string: String, within: [NSMenuItem]) -> [NSMenuItem] {
return within.filter({ item in
let range = item.title.range(
of: string,
options: .caseInsensitive,
range: nil,
locale: nil
)

return (range != nil)
})
}
}
25 changes: 2 additions & 23 deletions MaccyTests/MenuTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,10 @@ class MenuTests: XCTestCase {
}
}

func testSearchWithExactMatch() {
menu.updateFilter(filter: "foo")
XCTAssertEqual(menu.items, [menu.items[0], menuItems[0]])
}

func testSearchWithPartialMatch() {
menu.updateFilter(filter: "ba")
XCTAssertEqual(menu.items, [menu.items[0], menuItems[1], menuItems[2]])
}

func testSearchWithNoMatch() {
menu.updateFilter(filter: "xyz")
XCTAssertEqual(menu.items, [menu.items[0]])
}

func testSearchWithEmpty() {
menu.updateFilter(filter: "")
XCTAssertEqual(menu.items, [menu.items[0]] + menuItems)
}

func testSeparator() {
let separator = NSMenuItem.separator()
menu.addItem(separator)
menu.addItem(NSMenuItem.separator())
menu.updateFilter(filter: "xyz")
XCTAssertTrue(menu.items.contains(separator))
XCTAssertTrue(menu.items.contains(where: { $0.isSeparatorItem }))
}

func testSearchIsKept() {
Expand Down
45 changes: 45 additions & 0 deletions MaccyTests/SearchTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import XCTest
@testable import Maccy

class SearchTests: XCTestCase {
let savedFuzzySearch = UserDefaults.standard.bool(forKey: "fuzzySearch")

let items = [
NSMenuItem(title: "foo bar baz", action: nil, keyEquivalent: ""),
NSMenuItem(title: "foo bar zaz", action: nil, keyEquivalent: ""),
NSMenuItem(title: "xxx yyy zzz", action: nil, keyEquivalent: "")
]

override func tearDown() {
super.tearDown()
UserDefaults.standard.set(savedFuzzySearch, forKey: "fuzzySearch")
}

func testSimpleSearch() {
UserDefaults.standard.set(false, forKey: "fuzzySearch")

XCTAssertEqual(search(""), items)
XCTAssertEqual(search("z"), items)
XCTAssertEqual(search("foo"), [items[0], items[1]])
XCTAssertEqual(search("za"), [items[1]])
XCTAssertEqual(search("yyy"), [items[2]])
XCTAssertEqual(search("fbb"), [])
XCTAssertEqual(search("m"), [])
}

func testFuzzySearch() {
UserDefaults.standard.set(true, forKey: "fuzzySearch")

XCTAssertEqual(search(""), items)
XCTAssertEqual(search("z"), [items[1], items[2], items[0]])
XCTAssertEqual(search("foo"), [items[0], items[1]])
XCTAssertEqual(search("za"), [items[1], items[0], items[2]])
XCTAssertEqual(search("yyy"), [items[2]])
XCTAssertEqual(search("fbb"), [items[0], items[1]])
XCTAssertEqual(search("m"), [])
}

private func search(_ string: String) -> [NSMenuItem] {
return Search().search(string: string, within: items)
}
}
1 change: 1 addition & 0 deletions Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ platform :osx, '10.14'
target 'Maccy' do
use_frameworks!

pod 'Fuse', '~> 1.2'
pod 'HotKey', '>= 0.1.2'
pod 'SwiftHEXColors', '~> 1.3'

Expand Down
6 changes: 5 additions & 1 deletion Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
PODS:
- Fuse (1.2.0)
- HotKey (0.1.2)
- SwiftHEXColors (1.3.0)

DEPENDENCIES:
- Fuse (~> 1.2)
- HotKey (>= 0.1.2)
- SwiftHEXColors (~> 1.3)

SPEC REPOS:
https://github.com/cocoapods/specs.git:
- Fuse
- HotKey
- SwiftHEXColors

SPEC CHECKSUMS:
Fuse: 287bd95a5c8dbfe6c41af519eaf48856aa1a28f6
HotKey: ad59450195936c10992438c4210f673de5aee43e
SwiftHEXColors: 15ab5242cc2efeab548c4c74793b173b78dbae1c

PODFILE CHECKSUM: 7044fc9787ba9dcf0006e69334c57a73a05b8191
PODFILE CHECKSUM: d27bca7be5578c064699e96986f8af7f0995ce88

COCOAPODS: 1.5.2

0 comments on commit aaad717

Please sign in to comment.