diff --git a/spec/app/autocomplete-spec.coffee b/spec/app/autocomplete-spec.coffee index da668125b..19f15ab6b 100644 --- a/spec/app/autocomplete-spec.coffee +++ b/spec/app/autocomplete-spec.coffee @@ -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 diff --git a/src/app/autocomplete.coffee b/src/app/autocomplete.coffee index 2b2c9e76f..4d1b541c8 100644 --- a/src/app/autocomplete.coffee +++ b/src/app/autocomplete.coffee @@ -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)) diff --git a/src/app/file-finder.coffee b/src/app/file-finder.coffee index fa91fa8c8..cf124b901 100644 --- a/src/app/file-finder.coffee +++ b/src/app/file-finder.coffee @@ -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() diff --git a/src/stdlib/fuzzy-filter.coffee b/src/stdlib/fuzzy-filter.coffee new file mode 100644 index 000000000..33bfbd5a5 --- /dev/null +++ b/src/stdlib/fuzzy-filter.coffee @@ -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 diff --git a/src/stdlib/jquery-extensions.coffee b/src/stdlib/jquery-extensions.coffee index aef35305f..089884618 100644 --- a/src/stdlib/jquery-extensions.coffee +++ b/src/stdlib/jquery-extensions.coffee @@ -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]