From 52436f47a992b0c2f28d24d3f4767858f1c9e706 Mon Sep 17 00:00:00 2001 From: Lucas dos Santos Abreu Date: Mon, 2 Nov 2020 18:27:06 -0300 Subject: [PATCH] [AlecAivazis/survey#96] added input suggestions (#304) * (feat): issue templates based on CONTRIBUTION.md * (chore): vi does not behave the same with vim users :frowning_face: * (feat): render struct * (feat): implementing controls * tab for complete (and complete the completion) * arrows to navigate between suggestions * cancel last suggestion to typed answer * keeping previous scenarios intact * (feat): usage example * (feat): add to readme * (fix): suggestions * (fix): tab and help on the same brackets * (feat): when tab with input suggestion, select next * (feat): select and multiselect cycle next with tab --- .github/ISSUE_TEMPLATE/ask-for-help.md | 10 + .github/ISSUE_TEMPLATE/bug_report.md | 16 ++ .github/ISSUE_TEMPLATE/others-suggestions.md | 10 + README.md | 18 ++ editor_test.go | 4 + examples/inputfilesuggestion.go | 42 +++++ input.go | 155 ++++++++++++---- input_test.go | 186 ++++++++++++++++++- multiselect.go | 2 +- multiselect_test.go | 21 ++- select.go | 4 +- select_test.go | 16 ++ survey.go | 16 +- terminal/sequences.go | 1 + 14 files changed, 457 insertions(+), 44 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/ask-for-help.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/others-suggestions.md create mode 100644 examples/inputfilesuggestion.go diff --git a/.github/ISSUE_TEMPLATE/ask-for-help.md b/.github/ISSUE_TEMPLATE/ask-for-help.md new file mode 100644 index 00000000..644920ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ask-for-help.md @@ -0,0 +1,10 @@ +--- +name: Ask for help +about: Suggest an idea for this project or ask for help +title: '' +labels: 'Help Wanted' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..326e0a44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,16 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'Bug' +assignees: '' + +--- + +**What operating system and terminal are you using?** + +**An example that showcases the bug.** + +**What did you expect to see?** + +**What did you see instead?** diff --git a/.github/ISSUE_TEMPLATE/others-suggestions.md b/.github/ISSUE_TEMPLATE/others-suggestions.md new file mode 100644 index 00000000..6e7d6000 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/others-suggestions.md @@ -0,0 +1,10 @@ +--- +name: Others/suggestions +about: Suggestions and other topics +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/README.md b/README.md index 511c4140..4569ad7d 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ func main() { 1. [Running the Prompts](#running-the-prompts) 1. [Prompts](#prompts) 1. [Input](#input) + 1. [Suggestion Options](#suggestion-options) 1. [Multiline](#multiline) 1. [Password](#password) 1. [Confirm](#confirm) @@ -137,6 +138,23 @@ prompt := &survey.Input{ survey.AskOne(prompt, &name) ``` +#### Suggestion Options + + + +```golang +file := "" +prompt := &survey.Input{ + Message: "inform a file to save:", + Suggest: func (toComplete string) []string { + files, _ := filepath.Glob(toComplete + "*") + return files + }, +} +} +survey.AskOne(prompt, &file) +``` + ### Multiline diff --git a/editor_test.go b/editor_test.go index b6704370..97f1144a 100644 --- a/editor_test.go +++ b/editor_test.go @@ -102,6 +102,10 @@ func TestEditorRender(t *testing.T) { } func TestEditorPrompt(t *testing.T) { + if os.Getenv("SKIP_EDITOR_PROMPT_TESTS") != "" { + t.Skip("editor prompt tests skipped by dev") + } + if _, err := exec.LookPath("vi"); err != nil { t.Skip("vi not found in PATH") } diff --git a/examples/inputfilesuggestion.go b/examples/inputfilesuggestion.go new file mode 100644 index 00000000..8872cc4d --- /dev/null +++ b/examples/inputfilesuggestion.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "path/filepath" + + "github.com/AlecAivazis/survey/v2" +) + +func suggestFiles(toComplete string) []string { + files, _ := filepath.Glob(toComplete + "*") + return files +} + +// the questions to ask +var q = []*survey.Question{ + { + Name: "file", + Prompt: &survey.Input{ + Message: "Which file should be read?", + Suggest: suggestFiles, + Help: "Any file; do not need to exist yet", + }, + Validate: survey.Required, + }, +} + +func main() { + answers := struct { + File string + }{} + + // ask the question + err := survey.Ask(q, &answers) + + if err != nil { + fmt.Println(err.Error()) + return + } + // print the answers + fmt.Printf("File chosen %s.\n", answers.File) +} diff --git a/input.go b/input.go index 407701a6..f34177fd 100644 --- a/input.go +++ b/input.go @@ -1,5 +1,10 @@ package survey +import ( + "github.com/AlecAivazis/survey/v2/core" + "github.com/AlecAivazis/survey/v2/terminal" +) + /* Input is a regular text input that prints each character the user types on the screen and accepts the input with the enter key. Response type is a string. @@ -10,18 +15,26 @@ and accepts the input with the enter key. Response type is a string. */ type Input struct { Renderer - Message string - Default string - Help string + Message string + Default string + Help string + Suggest func(toComplete string) []string + typedAnswer string + answer string + options []core.OptionAnswer + selectedIndex int + showingHelp bool } // data available to the templates when processing type InputTemplateData struct { Input - Answer string - ShowAnswer bool - ShowHelp bool - Config *PromptConfig + ShowAnswer bool + ShowHelp bool + Answer string + PageEntries []core.OptionAnswer + SelectedIndex int + Config *PromptConfig } // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format @@ -31,11 +44,92 @@ var InputQuestionTemplate = ` {{- color "default+hb"}}{{ .Message }} {{color "reset"}} {{- if .ShowAnswer}} {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} +{{- else if .PageEntries -}} + {{- .Answer}} [Use arrows to move, enter to select, type to continue] + {{- "\n"}} + {{- range $ix, $choice := .PageEntries}} + {{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} + {{- $choice.Value}} + {{- color "reset"}}{{"\n"}} + {{- end}} {{- else }} - {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ print .Config.HelpInput }} for help]{{color "reset"}} {{end}} + {{- if or (and .Help (not .ShowHelp)) .Suggest }}{{color "cyan"}}[ + {{- if and .Help (not .ShowHelp)}}{{ print .Config.HelpInput }} for help {{- if and .Suggest}}, {{end}}{{end -}} + {{- if and .Suggest }}{{color "cyan"}}{{ print .Config.SuggestInput }} for suggestions{{end -}} + ]{{color "reset"}} {{end}} {{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} + {{- .Answer -}} {{- end}}` +func (i *Input) OnChange(key rune, config *PromptConfig) (bool, error) { + if key == terminal.KeyEnter || key == '\n' { + if i.answer != config.HelpInput || i.Help == "" { + // we're done + return true, nil + } else { + i.answer = "" + i.showingHelp = true + } + } else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine { + i.answer = "" + } else if key == terminal.KeyEscape && i.Suggest != nil { + if len(i.options) > 0 { + i.answer = i.typedAnswer + } + i.options = nil + } else if key == terminal.KeyArrowUp && len(i.options) > 0 { + if i.selectedIndex == 0 { + i.selectedIndex = len(i.options) - 1 + } else { + i.selectedIndex-- + } + i.answer = i.options[i.selectedIndex].Value + } else if (key == terminal.KeyArrowDown || key == terminal.KeyTab) && len(i.options) > 0 { + if i.selectedIndex == len(i.options)-1 { + i.selectedIndex = 0 + } else { + i.selectedIndex++ + } + i.answer = i.options[i.selectedIndex].Value + } else if key == terminal.KeyTab && i.Suggest != nil { + options := i.Suggest(i.answer) + i.selectedIndex = 0 + i.typedAnswer = i.answer + if len(options) > 0 { + i.answer = options[0] + if len(options) == 1 { + i.options = nil + } else { + i.options = core.OptionAnswerList(options) + } + } + } else if key == terminal.KeyDelete || key == terminal.KeyBackspace { + if i.answer != "" { + i.answer = i.answer[0 : len(i.answer)-1] + } + } else if key >= terminal.KeySpace { + i.answer += string(key) + i.typedAnswer = i.answer + i.options = nil + } + + pageSize := config.PageSize + opts, idx := paginate(pageSize, i.options, i.selectedIndex) + err := i.Render( + InputQuestionTemplate, + InputTemplateData{ + Input: *i, + Answer: i.answer, + ShowHelp: i.showingHelp, + SelectedIndex: idx, + PageEntries: opts, + Config: config, + }, + ) + + return err != nil, err +} + func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { // render the template err := i.Render( @@ -55,41 +149,39 @@ func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { defer rr.RestoreTermMode() cursor := i.NewCursor() + cursor.Hide() // hide the cursor + defer cursor.Show() // show the cursor when we're done - line := []rune{} - // get the next line + // start waiting for input for { - line, err = rr.ReadLine(0) + r, _, err := rr.ReadRune() if err != nil { - return string(line), err + return "", err } - // terminal will echo the \n so we need to jump back up one row - cursor.Up(1) - - if string(line) == config.HelpInput && i.Help != "" { - err = i.Render( - InputQuestionTemplate, - InputTemplateData{ - Input: *i, - ShowHelp: true, - Config: config, - }, - ) - if err != nil { - return "", err - } - continue + if r == terminal.KeyInterrupt { + return "", terminal.InterruptErr + } + if r == terminal.KeyEndTransmission { + break + } + + b, err := i.OnChange(r, config) + if err != nil { + return "", err + } + + if b { + break } - break } // if the line is empty - if line == nil || len(line) == 0 { + if len(i.answer) == 0 { // use the default value return i.Default, err } - lineStr := string(line) + lineStr := i.answer i.AppendRenderedText(lineStr) @@ -102,7 +194,6 @@ func (i *Input) Cleanup(config *PromptConfig, val interface{}) error { InputQuestionTemplate, InputTemplateData{ Input: *i, - Answer: val.(string), ShowAnswer: true, Config: config, }, diff --git a/input_test.go b/input_test.go index 564d15b1..52dce046 100644 --- a/input_test.go +++ b/input_test.go @@ -20,6 +20,8 @@ func init() { func TestInputRender(t *testing.T) { + suggestFn := func(string) (s []string) { return s } + tests := []struct { title string prompt Input @@ -41,7 +43,7 @@ func TestInputRender(t *testing.T) { { "Test Input answer output", Input{Message: "What is your favorite month:"}, - InputTemplateData{Answer: "October", ShowAnswer: true}, + InputTemplateData{ShowAnswer: true, Answer: "October"}, fmt.Sprintf("%s What is your favorite month: October\n", defaultIcons().Question.Text), }, { @@ -68,6 +70,47 @@ func TestInputRender(t *testing.T) { InputTemplateData{ShowHelp: true}, fmt.Sprintf("%s This is helpful\n%s What is your favorite month: (April) ", defaultIcons().Help.Text, defaultIcons().Question.Text), }, + { + "Test Input question output with completion", + Input{Message: "What is your favorite month:", Suggest: suggestFn}, + InputTemplateData{}, + fmt.Sprintf("%s What is your favorite month: [%s for suggestions] ", defaultIcons().Question.Text, string(defaultPromptConfig().SuggestInput)), + }, + { + "Test Input question output with suggestions and help hidden", + Input{Message: "What is your favorite month:", Suggest: suggestFn, Help: "This is helpful"}, + InputTemplateData{}, + fmt.Sprintf("%s What is your favorite month: [%s for help, %s for suggestions] ", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput), string(defaultPromptConfig().SuggestInput)), + }, + { + "Test Input question output with suggestions and default and help hidden", + Input{Message: "What is your favorite month:", Suggest: suggestFn, Help: "This is helpful", Default: "April"}, + InputTemplateData{}, + fmt.Sprintf("%s What is your favorite month: [%s for help, %s for suggestions] (April) ", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput), string(defaultPromptConfig().SuggestInput)), + }, + { + "Test Input question output with suggestions shown", + Input{Message: "What is your favorite month:", Suggest: suggestFn}, + InputTemplateData{ + Answer: "February", + PageEntries: core.OptionAnswerList([]string{"January", "February", "March", "etc..."}), + SelectedIndex: 1, + }, + fmt.Sprintf( + "%s What is your favorite month: February [Use arrows to move, enter to select, type to continue]\n"+ + " January\n%s February\n March\n etc...\n", + defaultIcons().Question.Text, defaultPromptConfig().Icons.SelectFocus.Text, + ), + }, + { + "Test Input question output with suggestion complemented", + Input{Message: "What is your favorite month:", Suggest: suggestFn}, + InputTemplateData{Answer: "February and"}, + fmt.Sprintf( + "%s What is your favorite month: [%s for suggestions] February and", + defaultIcons().Question.Text, defaultPromptConfig().SuggestInput, + ), + }, } for _, test := range tests { @@ -95,6 +138,7 @@ func TestInputRender(t *testing.T) { } func TestInputPrompt(t *testing.T) { + tests := []PromptTest{ { "Test Input prompt interaction", @@ -165,6 +209,146 @@ func TestInputPrompt(t *testing.T) { }, "R", }, + { + "Test Input prompt interaction when ask for suggestion with empty value", + &Input{ + Message: "What is your favorite month?", + Suggest: func(string) []string { + return []string{"January", "February"} + }, + }, + func(c *expect.Console) { + c.ExpectString("What is your favorite month?") + c.Send(string(terminal.KeyTab)) + c.ExpectString("January") + c.ExpectString("February") + c.SendLine("") + c.ExpectEOF() + }, + "January", + }, + { + "Test Input prompt interaction when ask for suggestion with some value", + &Input{ + Message: "What is your favorite month?", + Suggest: func(string) []string { + return []string{"February"} + }, + }, + func(c *expect.Console) { + c.ExpectString("What is your favorite month?") + c.Send("feb") + c.Send(string(terminal.KeyTab)) + c.SendLine("") + c.ExpectEOF() + }, + "February", + }, + { + "Test Input prompt interaction when ask for suggestion with some value, choosing the second one", + &Input{ + Message: "What is your favorite month?", + Suggest: func(string) []string { + return []string{"January", "February", "March"} + }, + }, + func(c *expect.Console) { + c.ExpectString("What is your favorite month?") + c.Send(string(terminal.KeyTab)) + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowDown)) + c.SendLine("") + c.ExpectEOF() + }, + "March", + }, + { + "Test Input prompt interaction when ask for suggestion with some value, choosing the second one", + &Input{ + Message: "What is your favorite month?", + Suggest: func(string) []string { + return []string{"January", "February", "March"} + }, + }, + func(c *expect.Console) { + c.ExpectString("What is your favorite month?") + c.Send(string(terminal.KeyTab)) + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowUp)) + c.SendLine("") + c.ExpectEOF() + }, + "February", + }, + { + "Test Input prompt interaction when ask for suggestion, complementing it and get new suggestions", + &Input{ + Message: "Where to save it?", + Suggest: func(complete string) []string { + if complete == "" { + return []string{"folder1/", "folder2/", "folder3/"} + } + return []string{"folder3/file1.txt", "folder3/file2.txt"} + }, + }, + func(c *expect.Console) { + c.ExpectString("Where to save it?") + c.Send(string(terminal.KeyTab)) + c.ExpectString("folder1/") + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowDown)) + c.Send("f") + c.Send(string(terminal.KeyTab)) + c.ExpectString("folder3/file2.txt") + c.Send(string(terminal.KeyArrowDown)) + c.SendLine("") + c.ExpectEOF() + }, + "folder3/file2.txt", + }, + { + "Test Input prompt interaction when asked suggestions, but abort suggestions", + &Input{ + Message: "Wanna a suggestion?", + Suggest: func(string) []string { + return []string{"suggest1", "suggest2"} + }, + }, + func(c *expect.Console) { + c.ExpectString("Wanna a suggestion?") + c.Send("typed answer") + c.Send(string(terminal.KeyTab)) + c.ExpectString("suggest1") + c.Send(string(terminal.KeyEscape)) + c.ExpectString("typed answer") + c.SendLine("") + c.ExpectEOF() + }, + "typed answer", + }, + { + "Test Input prompt interaction with suggestions, when tabbed with list being shown, should select next suggestion", + &Input{ + Message: "Choose the special one:", + Suggest: func(string) []string { + return []string{"suggest1", "suggest2", "special answer"} + }, + }, + func(c *expect.Console) { + c.ExpectString("Choose the special one:") + c.Send("s") + c.Send(string(terminal.KeyTab)) + c.ExpectString("suggest1") + c.ExpectString("suggest2") + c.ExpectString("special answer") + c.Send(string(terminal.KeyTab)) + c.Send(string(terminal.KeyTab)) + c.SendLine("") + c.ExpectEOF() + }, + "special answer", + }, } for _, test := range tests { diff --git a/multiselect.go b/multiselect.go index cf0da038..0e434776 100644 --- a/multiselect.go +++ b/multiselect.go @@ -77,7 +77,7 @@ func (m *MultiSelect) OnChange(key rune, config *PromptConfig) { // decrement the selected index m.selectedIndex-- } - } else if key == terminal.KeyArrowDown || (m.VimMode && key == 'j') { + } else if key == terminal.KeyTab || key == terminal.KeyArrowDown || (m.VimMode && key == 'j') { // if we are at the bottom of the list if m.selectedIndex == len(options)-1 { // start at the top diff --git a/multiselect_test.go b/multiselect_test.go index 9c380c0d..c2fd403b 100644 --- a/multiselect_test.go +++ b/multiselect_test.go @@ -171,6 +171,26 @@ func TestMultiSelectPrompt(t *testing.T) { }, []core.OptionAnswer{core.OptionAnswer{Value: "Monday", Index: 1}}, }, + { + "cycle to next when tab send", + &MultiSelect{ + Message: "What days do you prefer:", + Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, + }, + func(c *expect.Console) { + c.ExpectString("What days do you prefer: [Use arrows to move, space to select, to all, to none, type to filter]") + // Select Monday. + c.Send(string(terminal.KeyTab)) + c.Send(" ") + c.Send(string(terminal.KeyArrowDown)) + c.SendLine(" ") + c.ExpectEOF() + }, + []core.OptionAnswer{ + core.OptionAnswer{Value: "Monday", Index: 1}, + core.OptionAnswer{Value: "Tuesday", Index: 2}, + }, + }, { "default value as []string", &MultiSelect{ @@ -468,7 +488,6 @@ func TestMultiSelectPrompt(t *testing.T) { core.OptionAnswer{Value: "Saturday", Index: 6}, }, }, - } for _, test := range tests { diff --git a/select.go b/select.go index ac369ba9..bc564aa4 100644 --- a/select.go +++ b/select.go @@ -78,7 +78,7 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool { return false // if the user pressed the up arrow or 'k' to emulate vim - } else if key == terminal.KeyArrowUp || (s.VimMode && key == 'k') && len(options) > 0 { + } else if (key == terminal.KeyArrowUp || (s.VimMode && key == 'k')) && len(options) > 0 { s.useDefault = false // if we are at the top of the list @@ -91,7 +91,7 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool { } // if the user pressed down or 'j' to emulate vim - } else if key == terminal.KeyArrowDown || (s.VimMode && key == 'j') && len(options) > 0 { + } else if (key == terminal.KeyTab || key == terminal.KeyArrowDown || (s.VimMode && key == 'j')) && len(options) > 0 { s.useDefault = false // if we are at the bottom of the list if s.selectedIndex == len(options)-1 { diff --git a/select_test.go b/select_test.go index fc3b985d..c960e314 100644 --- a/select_test.go +++ b/select_test.go @@ -134,6 +134,22 @@ func TestSelectPrompt(t *testing.T) { }, core.OptionAnswer{Index: 1, Value: "blue"}, }, + { + "basic interaction", + &Select{ + Message: "Choose a color:", + Options: []string{"red", "blue", "green"}, + }, + func(c *expect.Console) { + c.ExpectString("Choose a color:") + // Select blue. + c.Send(string(terminal.KeyArrowDown)) + // Select green. + c.SendLine(string(terminal.KeyTab)) + c.ExpectEOF() + }, + core.OptionAnswer{Index: 2, Value: "green"}, + }, { "default value", &Select{ diff --git a/survey.go b/survey.go index ede27272..e004cf30 100644 --- a/survey.go +++ b/survey.go @@ -19,8 +19,9 @@ func defaultAskOptions() *AskOptions { Err: os.Stderr, }, PromptConfig: PromptConfig{ - PageSize: 7, - HelpInput: "?", + PageSize: 7, + HelpInput: "?", + SuggestInput: "tab", Icons: IconSet{ Error: Icon{ Text: "X", @@ -107,11 +108,12 @@ type Question struct { // PromptConfig holds the global configuration for a prompt type PromptConfig struct { - PageSize int - Icons IconSet - HelpInput string - Filter func(filter string, option string, index int) bool - KeepFilter bool + PageSize int + Icons IconSet + HelpInput string + SuggestInput string + Filter func(filter string, option string, index int) bool + KeepFilter bool } // Prompt is the primary interface for the objects that can take user input diff --git a/terminal/sequences.go b/terminal/sequences.go index a9158c63..6d9e8775 100644 --- a/terminal/sequences.go +++ b/terminal/sequences.go @@ -23,6 +23,7 @@ const ( SpecialKeyEnd = '\x11' SpecialKeyDelete = '\x12' IgnoreKey = '\000' + KeyTab = '\t' ) func soundBell(out io.Writer) {