Start with improved search

Fixes #70
This commit is contained in:
erroneousboat 2017-12-17 13:48:20 +01:00
parent 45b1dac1bf
commit 4234a63d7d
7 changed files with 335 additions and 47 deletions

View File

@ -1,9 +1,10 @@
package components package components
import ( import (
"strings" "sort"
"github.com/erroneousboat/termui" "github.com/erroneousboat/termui"
"github.com/renstrom/fuzzysearch/fuzzy"
) )
const ( const (
@ -21,6 +22,9 @@ type Channels struct {
SelectedChannel int // index of which channel is selected from the List SelectedChannel int // index of which channel is selected from the List
Offset int // from what offset are channels rendered Offset int // from what offset are channels rendered
CursorPosition int // the y position of the 'cursor' CursorPosition int // the y position of the 'cursor'
SearchMatches []int // index of the search matches
SearchPosition int // current position of a search match
} }
// CreateChannels is the constructor for the Channels component // CreateChannels is the constructor for the Channels component
@ -196,37 +200,70 @@ func (c *Channels) ScrollDown() {
// when a match has been found the selected channel will then // when a match has been found the selected channel will then
// be the channel that has been found // be the channel that has been found
func (c *Channels) Search(term string) { func (c *Channels) Search(term string) {
for i, item := range c.List.Items { c.SearchMatches = make([]int, 0)
if strings.Contains(item, term) {
// The new position matches := fuzzy.RankFind(term, c.List.Items)
newPos := i sort.Sort(matches)
// Is the new position in range of the current view? for _, m := range matches {
minRange := c.Offset for i, item := range c.List.Items {
maxRange := c.Offset + (c.List.InnerBounds().Max.Y - 2) if m.Target == item {
c.SearchMatches = append(c.SearchMatches, i)
if newPos < minRange { break
// newPos is above, we need to scroll up.
c.SetSelectedChannel(i)
// How much do we need to scroll to get it into range?
c.Offset = c.Offset - (minRange - newPos)
} else if newPos > maxRange {
// newPos is below, we need to scroll down
c.SetSelectedChannel(i)
// How much do we need to scroll to get it into range?
c.Offset = c.Offset + (newPos - maxRange)
} else {
// newPos is inside range
c.SetSelectedChannel(i)
} }
// Set cursor to correct position
c.CursorPosition = (newPos - c.Offset) + 1
break
} }
} }
c.GotoPosition(0)
c.SearchPosition = 0
}
// GotoPosition is used by the search functionality to automatically
// scroll to a specific location in the channels component
func (c *Channels) GotoPosition(position int) {
// The new position
newPos := c.SearchMatches[position]
// Is the new position in range of the current view?
minRange := c.Offset
maxRange := c.Offset + (c.List.InnerBounds().Max.Y - 2)
if newPos < minRange {
// newPos is above, we need to scroll up.
c.SetSelectedChannel(newPos)
// How much do we need to scroll to get it into range?
c.Offset = c.Offset - (minRange - newPos)
} else if newPos > maxRange {
// newPos is below, we need to scroll down
c.SetSelectedChannel(newPos)
// How much do we need to scroll to get it into range?
c.Offset = c.Offset + (newPos - maxRange)
} else {
// newPos is inside range
c.SetSelectedChannel(newPos)
}
// Set cursor to correct position
c.CursorPosition = (newPos - c.Offset) + 1
}
// SearchNext allows us to cycle through the c.SearchMatches
func (c *Channels) SearchNext() {
newPosition := c.SearchPosition + 1
if newPosition <= len(c.SearchMatches)-1 {
c.GotoPosition(newPosition)
c.SearchPosition = newPosition
}
}
// SearchPrev allows us to cycle through the c.SearchMatches
func (c *Channels) SearchPrev() {
newPosition := c.SearchPosition - 1
if newPosition >= 0 {
c.GotoPosition(newPosition)
c.SearchPosition = newPosition
}
} }

View File

@ -39,6 +39,8 @@ func NewConfig(filepath string) (*Config, error) {
"<next>": "chat-down", "<next>": "chat-down",
"C-f": "chat-down", "C-f": "chat-down",
"C-d": "chat-down", "C-d": "chat-down",
"n": "channel-search-next",
"N": "channel-search-prev",
"q": "quit", "q": "quit",
"<f1>": "help", "<f1>": "help",
}, },

View File

@ -20,24 +20,26 @@ var timer *time.Timer
// these action names can then be used to bind them to specific keys // these action names can then be used to bind them to specific keys
// in the Config. // in the Config.
var actionMap = map[string]func(*context.AppContext){ var actionMap = map[string]func(*context.AppContext){
"space": actionSpace, "space": actionSpace,
"backspace": actionBackSpace, "backspace": actionBackSpace,
"delete": actionDelete, "delete": actionDelete,
"cursor-right": actionMoveCursorRight, "cursor-right": actionMoveCursorRight,
"cursor-left": actionMoveCursorLeft, "cursor-left": actionMoveCursorLeft,
"send": actionSend, "send": actionSend,
"quit": actionQuit, "quit": actionQuit,
"mode-insert": actionInsertMode, "mode-insert": actionInsertMode,
"mode-command": actionCommandMode, "mode-command": actionCommandMode,
"mode-search": actionSearchMode, "mode-search": actionSearchMode,
"clear-input": actionClearInput, "clear-input": actionClearInput,
"channel-up": actionMoveCursorUpChannels, "channel-up": actionMoveCursorUpChannels,
"channel-down": actionMoveCursorDownChannels, "channel-down": actionMoveCursorDownChannels,
"channel-top": actionMoveCursorTopChannels, "channel-top": actionMoveCursorTopChannels,
"channel-bottom": actionMoveCursorBottomChannels, "channel-bottom": actionMoveCursorBottomChannels,
"chat-up": actionScrollUpChat, "channel-search-next": actionSearchNextChannels,
"chat-down": actionScrollDownChat, "channel-search-prev": actionSearchPrevChannels,
"help": actionHelp, "chat-up": actionScrollUpChat,
"chat-down": actionScrollDownChat,
"help": actionHelp,
} }
func RegisterEventHandlers(ctx *context.AppContext) { func RegisterEventHandlers(ctx *context.AppContext) {
@ -318,6 +320,16 @@ func actionMoveCursorBottomChannels(ctx *context.AppContext) {
actionChangeChannel(ctx) actionChangeChannel(ctx)
} }
func actionSearchNextChannels(ctx *context.AppContext) {
ctx.View.Channels.SearchNext()
actionChangeChannel(ctx)
}
func actionSearchPrevChannels(ctx *context.AppContext) {
ctx.View.Channels.SearchPrev()
actionChangeChannel(ctx)
}
func actionChangeChannel(ctx *context.AppContext) { func actionChangeChannel(ctx *context.AppContext) {
// Clear messages from Chat pane // Clear messages from Chat pane
ctx.View.Chat.ClearMessages() ctx.View.Chat.ClearMessages()

21
vendor/github.com/renstrom/fuzzysearch/LICENSE generated vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Peter Renström
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

167
vendor/github.com/renstrom/fuzzysearch/fuzzy/fuzzy.go generated vendored Normal file
View File

@ -0,0 +1,167 @@
// Fuzzy searching allows for flexibly matching a string with partial input,
// useful for filtering data very quickly based on lightweight user input.
package fuzzy
import (
"unicode"
"unicode/utf8"
)
var noop = func(r rune) rune { return r }
// Match returns true if source matches target using a fuzzy-searching
// algorithm. Note that it doesn't implement Levenshtein distance (see
// RankMatch instead), but rather a simplified version where there's no
// approximation. The method will return true only if each character in the
// source can be found in the target and occurs after the preceding matches.
func Match(source, target string) bool {
return match(source, target, noop)
}
// MatchFold is a case-insensitive version of Match.
func MatchFold(source, target string) bool {
return match(source, target, unicode.ToLower)
}
func match(source, target string, fn func(rune) rune) bool {
lenDiff := len(target) - len(source)
if lenDiff < 0 {
return false
}
if lenDiff == 0 && source == target {
return true
}
Outer:
for _, r1 := range source {
for i, r2 := range target {
if fn(r1) == fn(r2) {
target = target[i+utf8.RuneLen(r2):]
continue Outer
}
}
return false
}
return true
}
// Find will return a list of strings in targets that fuzzy matches source.
func Find(source string, targets []string) []string {
return find(source, targets, noop)
}
// FindFold is a case-insensitive version of Find.
func FindFold(source string, targets []string) []string {
return find(source, targets, unicode.ToLower)
}
func find(source string, targets []string, fn func(rune) rune) []string {
var matches []string
for _, target := range targets {
if match(source, target, fn) {
matches = append(matches, target)
}
}
return matches
}
// RankMatch is similar to Match except it will measure the Levenshtein
// distance between the source and the target and return its result. If there
// was no match, it will return -1.
// Given the requirements of match, RankMatch only needs to perform a subset of
// the Levenshtein calculation, only deletions need be considered, required
// additions and substitutions would fail the match test.
func RankMatch(source, target string) int {
return rank(source, target, noop)
}
// RankMatchFold is a case-insensitive version of RankMatch.
func RankMatchFold(source, target string) int {
return rank(source, target, unicode.ToLower)
}
func rank(source, target string, fn func(rune) rune) int {
lenDiff := len(target) - len(source)
if lenDiff < 0 {
return -1
}
if lenDiff == 0 && source == target {
return 0
}
runeDiff := 0
Outer:
for _, r1 := range source {
for i, r2 := range target {
if fn(r1) == fn(r2) {
target = target[i+utf8.RuneLen(r2):]
continue Outer
} else {
runeDiff++
}
}
return -1
}
// Count up remaining char
for len(target) > 0 {
target = target[utf8.RuneLen(rune(target[0])):]
runeDiff++
}
return runeDiff
}
// RankFind is similar to Find, except it will also rank all matches using
// Levenshtein distance.
func RankFind(source string, targets []string) Ranks {
var r Ranks
for _, target := range find(source, targets, noop) {
distance := LevenshteinDistance(source, target)
r = append(r, Rank{source, target, distance})
}
return r
}
// RankFindFold is a case-insensitive version of RankFind.
func RankFindFold(source string, targets []string) Ranks {
var r Ranks
for _, target := range find(source, targets, unicode.ToLower) {
distance := LevenshteinDistance(source, target)
r = append(r, Rank{source, target, distance})
}
return r
}
type Rank struct {
// Source is used as the source for matching.
Source string
// Target is the word matched against.
Target string
// Distance is the Levenshtein distance between Source and Target.
Distance int
}
type Ranks []Rank
func (r Ranks) Len() int {
return len(r)
}
func (r Ranks) Swap(i, j int) {
r[i], r[j] = r[j], r[i]
}
func (r Ranks) Less(i, j int) bool {
return r[i].Distance < r[j].Distance
}

View File

@ -0,0 +1,43 @@
package fuzzy
// LevenshteinDistance measures the difference between two strings.
// The Levenshtein distance between two words is the minimum number of
// single-character edits (i.e. insertions, deletions or substitutions)
// required to change one word into the other.
//
// This implemention is optimized to use O(min(m,n)) space and is based on the
// optimized C version found here:
// http://en.wikibooks.org/wiki/Algorithm_implementation/Strings/Levenshtein_distance#C
func LevenshteinDistance(s, t string) int {
r1, r2 := []rune(s), []rune(t)
column := make([]int, len(r1)+1)
for y := 1; y <= len(r1); y++ {
column[y] = y
}
for x := 1; x <= len(r2); x++ {
column[0] = x
for y, lastDiag := 1, x-1; y <= len(r1); y++ {
oldDiag := column[y]
cost := 0
if r1[y-1] != r2[x-1] {
cost = 1
}
column[y] = min(column[y]+1, column[y-1]+1, lastDiag+cost)
lastDiag = oldDiag
}
}
return column[len(r1)]
}
func min(a, b, c int) int {
if a < b && a < c {
return a
} else if b < c {
return b
}
return c
}

6
vendor/vendor.json vendored
View File

@ -90,6 +90,12 @@
"revision": "aa4a75b1c20a2b03751b1a9f7e41d58bd6f71c43", "revision": "aa4a75b1c20a2b03751b1a9f7e41d58bd6f71c43",
"revisionTime": "2017-11-04T16:23:16Z" "revisionTime": "2017-11-04T16:23:16Z"
}, },
{
"checksumSHA1": "DF3jZEw4lCq/SEaC7DIl/R+7S70=",
"path": "github.com/renstrom/fuzzysearch/fuzzy",
"revision": "2d205ac6ec17a839a94bdbfd16d2fa6c6dada2e0",
"revisionTime": "2016-03-31T20:48:55Z"
},
{ {
"path": "go/ast", "path": "go/ast",
"revision": "" "revision": ""