Auto-complete matches can be fuzzy-filtered. Typing non-word characters confirms the match automatically.

Extract fuzzyFilter function into a file that is shared between file finder and autocompleter. Fix jQuery.fn.preempt to pass its arguments to the event handler.
This commit is contained in:
Nathan Sobo
2012-04-19 18:17:08 -06:00
parent cdd824960e
commit 0024cf89de
5 changed files with 98 additions and 31 deletions

View File

@@ -69,6 +69,13 @@ describe "Autocomplete", ->
expect(autocomplete.matchesList.find('li').length).toBe 1
expect(autocomplete.matchesList.find('li:eq(0)')).toHaveText('quicksort')
it "show's that there are no matches found when there is no prefix or suffix", ->
editor.setCursorBufferPosition([10, 0])
autocomplete.attach()
expect(autocomplete.matchesList.find('li').length).toBe 1
expect(autocomplete.matchesList.find('li:eq(0)')).toHaveText "No matches found"
describe "when text is selected", ->
it 'autocompletes word when there is only a prefix', ->
editor.buffer.insert([10,0] ,"extra:sort:extra")
@@ -164,6 +171,41 @@ describe "Autocomplete", ->
expect(autocomplete.find('li:eq(0)')).toHaveClass('selected')
expect(autocomplete.find('li:eq(1)')).not.toHaveClass('selected')
describe "when the mini-editor receives text input", ->
describe "when the text contains only word characters", ->
it "narrows the list of completions with the fuzzy match algorithm", ->
editor.buffer.insert([10,0] ,"t")
editor.setCursorBufferPosition([10,0])
autocomplete.attach()
expect(autocomplete.matchesList.find('li').length).toBe 8
miniEditor.textInput('i')
expect(autocomplete.matchesList.find('li').length).toBe 4
expect(autocomplete.matchesList.find('li:eq(0)')).toHaveText 'pivot'
expect(autocomplete.matchesList.find('li:eq(0)')).toHaveClass 'selected'
expect(autocomplete.matchesList.find('li:eq(1)')).toHaveText 'shift'
expect(autocomplete.matchesList.find('li:eq(2)')).toHaveText 'right'
expect(autocomplete.matchesList.find('li:eq(3)')).toHaveText 'quicksort'
expect(editor.lineForBufferRow(10)).toEqual 'pivot'
miniEditor.textInput('o')
expect(autocomplete.matchesList.find('li').length).toBe 2
expect(autocomplete.matchesList.find('li:eq(0)')).toHaveText 'pivot'
expect(autocomplete.matchesList.find('li:eq(1)')).toHaveText 'quicksort'
describe "when a non-word character is typed in the mini-editor", ->
it "immediately confirms the current completion choice and inserts that character into the buffer", ->
editor.buffer.insert([10,0] ,"t")
editor.setCursorBufferPosition([10,0])
autocomplete.attach()
miniEditor.textInput('iv')
expect(autocomplete.matchesList.find('li:eq(0)')).toHaveText 'pivot'
miniEditor.textInput(' ')
expect(autocomplete.parent()).not.toExist()
expect(editor.lineForBufferRow(10)).toEqual 'pivot '
describe 'when the editor is focused', ->
it "cancels the autocomplete", ->
autocomplete.attach()
@@ -179,17 +221,16 @@ describe "Autocomplete", ->
editor.buffer.change([[0,4],[0,13]], "sauron")
expect(autocomplete.buildWordList).toHaveBeenCalled()
describe "when the autocomplete menu is attached", ->
describe 'when the change was caused by autocomplete', ->
it 'does not rebuild the word list', ->
editor.buffer.insert([10,0] ,"extra:s:extra")
describe "when the autocomplete menu is attached and the change was caused by autocomplete itself", ->
it 'does not rebuild the word list', ->
editor.buffer.insert([10,0] ,"extra:s:extra")
spyOn(autocomplete, 'buildWordList')
editor.setCursorBufferPosition([10,7])
autocomplete.attach()
expect(autocomplete.buildWordList).not.toHaveBeenCalled()
spyOn(autocomplete, 'buildWordList')
editor.setCursorBufferPosition([10,7])
autocomplete.attach()
expect(autocomplete.buildWordList).not.toHaveBeenCalled()
describe "when editor's buffer is assigned a new buffer", ->
describe "when a new buffer is assigned on editor", ->
it 'creates and uses a new word list based on new buffer', ->
wordList = autocomplete.wordList
expect(wordList).toContain "quicksort"
@@ -253,9 +294,12 @@ describe "Autocomplete", ->
expect(autocomplete.position().left).toBeGreaterThan 0
describe ".detach()", ->
it "unbinds autocomplete event handlers for move-up and move-down", ->
it "clears the mini-editor and unbinds autocomplete event handlers for move-up and move-down", ->
autocomplete.attach()
miniEditor.buffer.setText('foo')
autocomplete.detach()
expect(miniEditor.buffer.getText()).toBe ''
editor.trigger 'move-down'
expect(editor.getCursorBufferPosition().row).toBe 1

View File

@@ -3,6 +3,7 @@ $ = require 'jquery'
_ = require 'underscore'
Range = require 'range'
Editor = require 'editor'
fuzzyFilter = require 'fuzzy-filter'
module.exports =
class Autocomplete extends View
@@ -37,6 +38,9 @@ class Autocomplete extends View
@on 'autocomplete:confirm', => @confirm()
@on 'autocomplete:cancel', => @cancel()
@miniEditor.buffer.on 'change', =>
@filterMatchList() if @parent()[0]
@miniEditor.preempt 'move-up', =>
@selectPreviousMatch()
false
@@ -45,6 +49,13 @@ class Autocomplete extends View
@selectNextMatch()
false
@miniEditor.preempt 'textInput', (e) =>
text = e.originalEvent.data
unless text.match(@wordRegex)
@confirm()
@editor.insertText(text)
false
setCurrentBuffer: (buffer) ->
@currentBuffer?.off '.autocomplete'
@currentBuffer = buffer
@@ -71,7 +82,6 @@ class Autocomplete extends View
@originalSelectedText = @editor.getSelectedText()
@originalSelectionBufferRange = @editor.getSelection().getBufferRange()
@buildMatchList()
@selectMatchAtIndex(0) if @matches.length > 0
cursorScreenPosition = @editor.getCursorScreenPosition()
{left, top} = @editor.pixelOffsetForScreenPosition(cursorScreenPosition)
@@ -83,6 +93,7 @@ class Autocomplete extends View
@editor.off(".autocomplete")
@editor.focus()
super
@miniEditor.buffer.setText('')
selectPreviousMatch: ->
previousIndex = @currentMatchIndex - 1
@@ -108,20 +119,27 @@ class Autocomplete extends View
buildMatchList: ->
selection = @editor.getSelection()
{prefix, suffix} = @prefixAndSuffixOfSelection(selection)
if (prefix.length + suffix.length) == 0
if (prefix.length + suffix.length) > 0
currentWord = prefix + @editor.getSelectedText() + suffix
@matches = (match for match in @wordMatches(prefix, suffix) when match.word != currentWord)
else
@matches = []
return
@renderMatchList()
currentWord = prefix + @editor.getSelectedText() + suffix
@matches = (match for match in @wordMatches(prefix, suffix) when match.word != currentWord)
filterMatchList: ->
@matches = fuzzyFilter(@matches, @miniEditor.buffer.getText(), key: 'word')
@renderMatchList()
renderMatchList: ->
@matchesList.empty()
if @matches.length > 0
@matchesList.append($$ -> @li match.word) for match in @matches
else
@matchesList.append($$ -> @li "No matches found")
@selectMatchAtIndex(0) if @matches.length > 0
buildWordList: () ->
@wordList = _.unique(@currentBuffer.getText().match(@wordRegex))

View File

@@ -1,6 +1,7 @@
$ = require 'jquery'
{View} = require 'space-pen'
stringScore = require 'stringscore'
fuzzyFilter = require 'fuzzy-filter'
Editor = require 'editor'
module.exports =
@@ -58,19 +59,7 @@ class FileFinder extends View
.addClass('selected')
findMatches: (query) ->
if not query
paths = @paths
else
scoredPaths = ({path, score: stringScore(path, query)} for path in @paths)
scoredPaths.sort (a, b) ->
if a.score > b.score then -1
else if a.score < b.score then 1
else 0
window.x = scoredPaths
paths = (pathAndScore.path for pathAndScore in scoredPaths when pathAndScore.score > 0)
paths.slice 0, @maxResults
fuzzyFilter(@paths, query, maxResults: @maxResults)
remove: ->
$('#panes .editor.active').focus()

View File

@@ -0,0 +1,16 @@
stringScore = require 'stringscore'
module.exports = (candidates, query, options) ->
if query
scoredCandidates = candidates.map (candidate) ->
string = if options.key? then candidate[options.key] else candidate
{ candidate, score: stringScore(string, query) }
scoredCandidates.sort (a, b) ->
if a.score > b.score then -1
else if a.score < b.score then 1
else 0
candidates = (scoredCandidate.candidate for scoredCandidate in scoredCandidates when scoredCandidate.score > 0)
candidates = candidates[0...options.maxResults] if options.maxResults?
candidates

View File

@@ -16,8 +16,8 @@ $.fn.containsElement = (element) ->
(element[0].compareDocumentPosition(this[0]) & 8) == 8
$.fn.preempt = (eventName, handler) ->
@on eventName, (e) ->
if handler() == false then e.stopImmediatePropagation()
@on eventName, (e, args...) ->
if handler(e, args...) == false then e.stopImmediatePropagation()
eventNameWithoutNamespace = eventName.split('.')[0]
handlers = @data('events')[eventNameWithoutNamespace]