From 4234a63d7dc7e395f4346f66cae52c5d5c7bb061 Mon Sep 17 00:00:00 2001 From: erroneousboat Date: Sun, 17 Dec 2017 13:48:20 +0100 Subject: [PATCH] Start with improved search Fixes #70 --- components/channels.go | 95 +++++++--- config/config.go | 2 + handlers/event.go | 48 +++-- .../github.com/renstrom/fuzzysearch/LICENSE | 21 +++ .../renstrom/fuzzysearch/fuzzy/fuzzy.go | 167 ++++++++++++++++++ .../renstrom/fuzzysearch/fuzzy/levenshtein.go | 43 +++++ vendor/vendor.json | 6 + 7 files changed, 335 insertions(+), 47 deletions(-) create mode 100644 vendor/github.com/renstrom/fuzzysearch/LICENSE create mode 100644 vendor/github.com/renstrom/fuzzysearch/fuzzy/fuzzy.go create mode 100644 vendor/github.com/renstrom/fuzzysearch/fuzzy/levenshtein.go diff --git a/components/channels.go b/components/channels.go index 73f5413..ed23a07 100644 --- a/components/channels.go +++ b/components/channels.go @@ -1,9 +1,10 @@ package components import ( - "strings" + "sort" "github.com/erroneousboat/termui" + "github.com/renstrom/fuzzysearch/fuzzy" ) const ( @@ -21,6 +22,9 @@ type Channels struct { SelectedChannel int // index of which channel is selected from the List Offset int // from what offset are channels rendered 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 @@ -196,37 +200,70 @@ func (c *Channels) ScrollDown() { // when a match has been found the selected channel will then // be the channel that has been found func (c *Channels) Search(term string) { - for i, item := range c.List.Items { - if strings.Contains(item, term) { + c.SearchMatches = make([]int, 0) - // The new position - newPos := i + matches := fuzzy.RankFind(term, c.List.Items) + sort.Sort(matches) - // 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(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) + for _, m := range matches { + for i, item := range c.List.Items { + if m.Target == item { + c.SearchMatches = append(c.SearchMatches, i) + break } - - // 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 + } } diff --git a/config/config.go b/config/config.go index cd7f0d3..a89f7eb 100644 --- a/config/config.go +++ b/config/config.go @@ -39,6 +39,8 @@ func NewConfig(filepath string) (*Config, error) { "": "chat-down", "C-f": "chat-down", "C-d": "chat-down", + "n": "channel-search-next", + "N": "channel-search-prev", "q": "quit", "": "help", }, diff --git a/handlers/event.go b/handlers/event.go index fd1121d..8199c99 100644 --- a/handlers/event.go +++ b/handlers/event.go @@ -20,24 +20,26 @@ var timer *time.Timer // these action names can then be used to bind them to specific keys // in the Config. var actionMap = map[string]func(*context.AppContext){ - "space": actionSpace, - "backspace": actionBackSpace, - "delete": actionDelete, - "cursor-right": actionMoveCursorRight, - "cursor-left": actionMoveCursorLeft, - "send": actionSend, - "quit": actionQuit, - "mode-insert": actionInsertMode, - "mode-command": actionCommandMode, - "mode-search": actionSearchMode, - "clear-input": actionClearInput, - "channel-up": actionMoveCursorUpChannels, - "channel-down": actionMoveCursorDownChannels, - "channel-top": actionMoveCursorTopChannels, - "channel-bottom": actionMoveCursorBottomChannels, - "chat-up": actionScrollUpChat, - "chat-down": actionScrollDownChat, - "help": actionHelp, + "space": actionSpace, + "backspace": actionBackSpace, + "delete": actionDelete, + "cursor-right": actionMoveCursorRight, + "cursor-left": actionMoveCursorLeft, + "send": actionSend, + "quit": actionQuit, + "mode-insert": actionInsertMode, + "mode-command": actionCommandMode, + "mode-search": actionSearchMode, + "clear-input": actionClearInput, + "channel-up": actionMoveCursorUpChannels, + "channel-down": actionMoveCursorDownChannels, + "channel-top": actionMoveCursorTopChannels, + "channel-bottom": actionMoveCursorBottomChannels, + "channel-search-next": actionSearchNextChannels, + "channel-search-prev": actionSearchPrevChannels, + "chat-up": actionScrollUpChat, + "chat-down": actionScrollDownChat, + "help": actionHelp, } func RegisterEventHandlers(ctx *context.AppContext) { @@ -318,6 +320,16 @@ func actionMoveCursorBottomChannels(ctx *context.AppContext) { 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) { // Clear messages from Chat pane ctx.View.Chat.ClearMessages() diff --git a/vendor/github.com/renstrom/fuzzysearch/LICENSE b/vendor/github.com/renstrom/fuzzysearch/LICENSE new file mode 100644 index 0000000..9cc7533 --- /dev/null +++ b/vendor/github.com/renstrom/fuzzysearch/LICENSE @@ -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. diff --git a/vendor/github.com/renstrom/fuzzysearch/fuzzy/fuzzy.go b/vendor/github.com/renstrom/fuzzysearch/fuzzy/fuzzy.go new file mode 100644 index 0000000..63277d5 --- /dev/null +++ b/vendor/github.com/renstrom/fuzzysearch/fuzzy/fuzzy.go @@ -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 +} diff --git a/vendor/github.com/renstrom/fuzzysearch/fuzzy/levenshtein.go b/vendor/github.com/renstrom/fuzzysearch/fuzzy/levenshtein.go new file mode 100644 index 0000000..237923d --- /dev/null +++ b/vendor/github.com/renstrom/fuzzysearch/fuzzy/levenshtein.go @@ -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 +} diff --git a/vendor/vendor.json b/vendor/vendor.json index a8b6765..a246ab4 100644 --- a/vendor/vendor.json +++ b/vendor/vendor.json @@ -90,6 +90,12 @@ "revision": "aa4a75b1c20a2b03751b1a9f7e41d58bd6f71c43", "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", "revision": ""