From cc172970a079bb78847f2276db8bfae375cda185 Mon Sep 17 00:00:00 2001 From: kt programs Date: Sun, 6 Mar 2022 10:58:07 +0800 Subject: [PATCH] commands: implement fuzzy completion for commands and options Change the option to enable fuzzy completion to be fuzzy-complete, since it's no longer only used for folders Signed-off-by: Kt Programs Acked-by: Koni Marti --- commands/account/recover.go | 35 +++++++++++++++++------ commands/account/sort.go | 15 ++++++---- commands/commands.go | 57 ++++++++----------------------------- commands/compose/header.go | 2 +- commands/ct.go | 13 ++++----- commands/msg/archive.go | 2 +- commands/util.go | 20 +++++++++++++ config/aerc.conf | 7 +++-- config/config.go | 4 +-- doc/aerc-config.5.scd | 10 +++---- go.mod | 1 + go.sum | 2 ++ 12 files changed, 90 insertions(+), 78 deletions(-) diff --git a/commands/account/recover.go b/commands/account/recover.go index a167d50..855d984 100644 --- a/commands/account/recover.go +++ b/commands/account/recover.go @@ -6,8 +6,8 @@ import ( "io/ioutil" "os" "path/filepath" - "strings" + "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" @@ -24,24 +24,43 @@ func (Recover) Aliases() []string { } func (Recover) Complete(aerc *widgets.Aerc, args []string) []string { + acct := aerc.SelectedAccount() + if acct == nil { + return make([]string, 0) + } + // file name of temp file is hard-coded in the NewComposer() function files, err := filepath.Glob( filepath.Join(os.TempDir(), "aerc-compose-*.eml"), ) if err != nil { - return []string{} + return make([]string, 0) } - arg := strings.Join(args, " ") - if arg != "" { - for i, file := range files { - files[i] = strings.Join([]string{arg, file}, " ") + // if nothing is entered yet, return all files + if len(args) == 0 { + return files + } + if args[0] == "-" { + return []string{"-f"} + } else if args[0] == "-f" { + if len(args) == 1 { + for i, file := range files { + files[i] = args[0] + " " + file + } + return files + } else { + // only accepts one file to recover + return commands.FilterList(files, args[1], args[0]+" ", acct.UiConfig().FuzzyComplete) } + } else { + // only accepts one file to recover + return commands.FilterList(files, args[0], "", acct.UiConfig().FuzzyComplete) } - return files } func (Recover) Execute(aerc *widgets.Aerc, args []string) error { - if len(Recover{}.Complete(aerc, args)) == 0 { + // Complete() expects to be passed only the arguments, not including the command name + if len(Recover{}.Complete(aerc, args[1:])) == 0 { return errors.New("No messages to recover.") } diff --git a/commands/account/sort.go b/commands/account/sort.go index 89a5e38..15ecbc0 100644 --- a/commands/account/sort.go +++ b/commands/account/sort.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/lib/sort" "git.sr.ht/~rjarry/aerc/widgets" ) @@ -19,6 +20,11 @@ func (Sort) Aliases() []string { } func (Sort) Complete(aerc *widgets.Aerc, args []string) []string { + acct := aerc.SelectedAccount() + if acct == nil { + return make([]string, 0) + } + supportedCriteria := []string{ "arrival", "cc", @@ -35,7 +41,7 @@ func (Sort) Complete(aerc *widgets.Aerc, args []string) []string { last := args[len(args)-1] var completions []string currentPrefix := strings.Join(args, " ") + " " - // if there is a completed criteria then suggest all again or an option + // if there is a completed criteria or option then suggest all again for _, criteria := range append(supportedCriteria, "-r") { if criteria == last { for _, criteria := range supportedCriteria { @@ -54,11 +60,8 @@ func (Sort) Complete(aerc *widgets.Aerc, args []string) []string { return []string{currentPrefix + "-r"} } // the last item is not complete - for _, criteria := range supportedCriteria { - if strings.HasPrefix(criteria, last) { - completions = append(completions, currentPrefix+criteria) - } - } + completions = commands.FilterList(supportedCriteria, last, currentPrefix, + acct.UiConfig().FuzzyComplete) return completions } diff --git a/commands/commands.go b/commands/commands.go index 70a77b9..c23df7e 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -2,7 +2,6 @@ package commands import ( "errors" - "fmt" "sort" "strings" "unicode" @@ -74,12 +73,14 @@ func (cmds *Commands) GetCompletions(aerc *widgets.Aerc, cmd string) []string { return nil } + // nothing entered, list all commands if len(args) == 0 { names := cmds.Names() sort.Strings(names) return names } + // complete options if len(args) > 1 || cmd[len(cmd)-1] == ' ' { if cmd, ok := cmds.dict()[args[0]]; ok { var completions []string @@ -101,13 +102,9 @@ func (cmds *Commands) GetCompletions(aerc *widgets.Aerc, cmd string) []string { return nil } + // complete available commands names := cmds.Names() - options := make([]string, 0) - for _, name := range names { - if strings.HasPrefix(name, args[0]) { - options = append(options, name) - } - } + options := FilterList(names, args[0], "", aerc.SelectedAccount().UiConfig().FuzzyComplete) if len(options) > 0 { return options @@ -116,35 +113,23 @@ func (cmds *Commands) GetCompletions(aerc *widgets.Aerc, cmd string) []string { } func GetFolders(aerc *widgets.Aerc, args []string) []string { - out := make([]string, 0) acct := aerc.SelectedAccount() if acct == nil { - return out + return make([]string, 0) } if len(args) == 0 { return acct.Directories().List() } - for _, dir := range acct.Directories().List() { - if foundInString(dir, args[0], acct.UiConfig().FuzzyFolderComplete) { - out = append(out, dir) - } - } - return out + return FilterList(acct.Directories().List(), args[0], "", acct.UiConfig().FuzzyComplete) } // CompletionFromList provides a convenience wrapper for commands to use in the // Complete function. It simply matches the items provided in valid -func CompletionFromList(valid []string, args []string) []string { - out := make([]string, 0) +func CompletionFromList(aerc *widgets.Aerc, valid []string, args []string) []string { if len(args) == 0 { return valid } - for _, v := range valid { - if hasCaseSmartPrefix(v, args[0]) { - out = append(out, v) - } - } - return out + return FilterList(valid, args[0], "", aerc.SelectedAccount().UiConfig().FuzzyComplete) } func GetLabels(aerc *widgets.Aerc, args []string) []string { @@ -172,27 +157,14 @@ func GetLabels(aerc *widgets.Aerc, args []string) []string { } trimmed := strings.TrimLeft(last, "+-") - out := make([]string, 0) - for _, label := range acct.Labels() { - if hasCaseSmartPrefix(label, trimmed) { - var prev string - if len(others) > 0 { - prev = others + " " - } - out = append(out, fmt.Sprintf("%v%v%v", prev, prefix, label)) - } + var prev string + if len(others) > 0 { + prev = others + " " } + out := FilterList(acct.Labels(), trimmed, prev+prefix, acct.UiConfig().FuzzyComplete) return out } -func foundInString(s, substring string, fuzzy bool) bool { - if fuzzy { - return caseInsensitiveContains(s, substring) - } else { - return hasCaseSmartPrefix(s, substring) - } -} - // hasCaseSmartPrefix checks whether s starts with prefix, using a case // sensitive match if and only if prefix contains upper case letters. func hasCaseSmartPrefix(s, prefix string) bool { @@ -202,11 +174,6 @@ func hasCaseSmartPrefix(s, prefix string) bool { return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) } -func caseInsensitiveContains(s, substr string) bool { - s, substr = strings.ToUpper(s), strings.ToUpper(substr) - return strings.Contains(s, substr) -} - func hasUpper(s string) bool { for _, r := range s { if unicode.IsUpper(r) { diff --git a/commands/compose/header.go b/commands/compose/header.go index 5780aa8..949698e 100644 --- a/commands/compose/header.go +++ b/commands/compose/header.go @@ -32,7 +32,7 @@ func (Header) Aliases() []string { } func (Header) Complete(aerc *widgets.Aerc, args []string) []string { - return commands.CompletionFromList(headers, args) + return commands.CompletionFromList(aerc, headers, args) } func (Header) Execute(aerc *widgets.Aerc, args []string) error { diff --git a/commands/ct.go b/commands/ct.go index 7764cab..f5f2cca 100644 --- a/commands/ct.go +++ b/commands/ct.go @@ -20,17 +20,16 @@ func (ChangeTab) Aliases() []string { } func (ChangeTab) Complete(aerc *widgets.Aerc, args []string) []string { + acct := aerc.SelectedAccount() + if acct == nil { + return make([]string, 0) + } + if len(args) == 0 { return aerc.TabNames() } joinedArgs := strings.Join(args, " ") - out := make([]string, 0) - for _, tab := range aerc.TabNames() { - if strings.HasPrefix(tab, joinedArgs) { - out = append(out, tab) - } - } - return out + return FilterList(aerc.TabNames(), joinedArgs, "", acct.UiConfig().FuzzyComplete) } func (ChangeTab) Execute(aerc *widgets.Aerc, args []string) error { diff --git a/commands/msg/archive.go b/commands/msg/archive.go index e73e42c..8f832e5 100644 --- a/commands/msg/archive.go +++ b/commands/msg/archive.go @@ -31,7 +31,7 @@ func (Archive) Aliases() []string { func (Archive) Complete(aerc *widgets.Aerc, args []string) []string { valid := []string{"flat", "year", "month"} - return commands.CompletionFromList(valid, args) + return commands.CompletionFromList(aerc, valid, args) } func (Archive) Execute(aerc *widgets.Aerc, args []string) error { diff --git a/commands/util.go b/commands/util.go index f3f9bc8..92b851a 100644 --- a/commands/util.go +++ b/commands/util.go @@ -10,6 +10,8 @@ import ( "strings" "time" + "github.com/lithammer/fuzzysearch/fuzzy" + "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/models" "git.sr.ht/~rjarry/aerc/widgets" @@ -194,3 +196,21 @@ func MsgInfoFromUids(store *lib.MessageStore, uids []uint32) ([]*models.MessageI } return infos, nil } + +// FilterList takes a list of valid completions and filters it, either +// by case smart prefix, or by fuzzy matching, prepending "prefix" to each completion +func FilterList(valid []string, search, prefix string, isFuzzy bool) []string { + out := make([]string, 0) + if isFuzzy { + for _, v := range fuzzy.RankFindFold(search, valid) { + out = append(out, prefix+v.Target) + } + } else { + for _, v := range valid { + if hasCaseSmartPrefix(v, search) { + out = append(out, prefix+v) + } + } + } + return out +} diff --git a/config/aerc.conf b/config/aerc.conf index a7628e3..fbbf587 100644 --- a/config/aerc.conf +++ b/config/aerc.conf @@ -124,9 +124,10 @@ stylesets-dirs= # Default: default styleset-name=default -# Activates fuzzy search for IMAP folders: the typed string is search in the -# folder tree in any position, not necessarily at the beginning. -#fuzzy-folder-complete=false +# Activates fuzzy search in commands and their arguments: the typed string is +# searched in the command or option in any position, and need not be +# consecutive characters in the command or option. +#fuzzy-complete=false #[ui:account=foo] # diff --git a/config/config.go b/config/config.go index 3fecb36..8a01f50 100644 --- a/config/config.go +++ b/config/config.go @@ -44,7 +44,7 @@ type UIConfig struct { EmptyDirlist string `ini:"empty-dirlist"` MouseEnabled bool `ini:"mouse-enabled"` ThreadingEnabled bool `ini:"threading-enabled"` - FuzzyFolderComplete bool `ini:"fuzzy-folder-complete"` + FuzzyComplete bool `ini:"fuzzy-complete"` NewMessageBell bool `ini:"new-message-bell"` Spinner string `ini:"spinner"` SpinnerDelimiter string `ini:"spinner-delimiter"` @@ -622,7 +622,7 @@ func LoadConfigFromFile(root *string, logger *log.Logger) (*AercConfig, error) { EmptyDirlist: "(no folders)", MouseEnabled: false, NewMessageBell: true, - FuzzyFolderComplete: false, + FuzzyComplete: false, Spinner: "[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] ", SpinnerDelimiter: ",", DirListFormat: "%n %>r", diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index 9e8451f..e172328 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -236,11 +236,11 @@ These options are configured in the *[ui]* section of aerc.conf. Have a look at *aerc-stylesets*(7) as to how a styleset looks like. -*fuzzy-folder-complete* - When finding a folder with cf or move, for example, the popover will - how not only the folders /starting/ with the string input by the user, - but it will show coincidences of folders /containing/ the string in any - position of their name. This is case-independent. +*fuzzy-complete* + When typing a command or option, the popover will now show not only the + items /starting/ with the string input by the user, but it will also show + instances of items /containing/ the string, starting at any position and + need not be consecutive characters in the command or option. *threading-enabled* Enable a threaded viewing of messages, works with IMAP (when there's diff --git a/go.mod b/go.mod index 4be2d83..fac7bb3 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/imdario/mergo v0.3.12 github.com/kyoh86/xdg v1.2.0 + github.com/lithammer/fuzzysearch v1.1.3 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.14 github.com/mattn/go-pointer v0.0.1 // indirect diff --git a/go.sum b/go.sum index b828719..48315d1 100644 --- a/go.sum +++ b/go.sum @@ -168,6 +168,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kyoh86/xdg v1.2.0 h1:CERuT/ShdTDj+A2UaX3hQ3mOV369+Sj+wyn2nIRIIkI= github.com/kyoh86/xdg v1.2.0/go.mod h1:/mg8zwu1+qe76oTFUBnyS7rJzk7LLC0VGEzJyJ19DHs= +github.com/lithammer/fuzzysearch v1.1.3 h1:+t5SevHLfi3IHcTx7LT3S+od4OcUmjzxD1xmnvtgG38= +github.com/lithammer/fuzzysearch v1.1.3/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=