From 3ec04a9bc169698c44aad7ca54eee24a874549a1 Mon Sep 17 00:00:00 2001 From: Lucas dos Santos Abreu Date: Fri, 2 Jul 2021 17:35:57 -0300 Subject: [PATCH] (fix): arrow key behavior on input prompt (#361) * first pass at input using RuneReader.ReadLine with suggestions * fix newline from readline * better callback name * (fix): skip tests with editor * (feat): allow prompt callback on rune input and initial input * (fix): fallback to ReadLine when not using "auto complete" * (feat): tests input navagation * (fix): suggestions can be dismissed * (fix): editor blocking testing Co-authored-by: Alec Aivazis --- _tasks.hcl | 4 +- editor_test.go | 12 +-- input.go | 161 ++++++++++++++++++++++------------------- input_test.go | 57 ++++++++++++--- survey_test.go | 6 +- terminal/runereader.go | 32 +++++++- 6 files changed, 174 insertions(+), 98 deletions(-) diff --git a/_tasks.hcl b/_tasks.hcl index aaa47687..0eefcb54 100644 --- a/_tasks.hcl +++ b/_tasks.hcl @@ -1,13 +1,13 @@ task "install-deps" { description = "Install all of package dependencies" pipeline = [ - "go get {{.files}}", + "go get -v {{.files}}", ] } task "tests" { description = "Run the test suite" - command = "go test {{.files}}" + command = "go test -v {{.files}}" environment = { GOFLAGS = "-mod=vendor" } diff --git a/editor_test.go b/editor_test.go index 97f1144a..5ad9d627 100644 --- a/editor_test.go +++ b/editor_test.go @@ -102,10 +102,6 @@ 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") } @@ -122,7 +118,7 @@ func TestEditorPrompt(t *testing.T) { c.SendLine("") go c.ExpectEOF() time.Sleep(time.Millisecond) - c.Send("iAdd editor prompt tests\x1b") + c.Send("ccAdd editor prompt tests\x1b") c.SendLine(":wq!") }, "Add editor prompt tests\n", @@ -155,7 +151,7 @@ func TestEditorPrompt(t *testing.T) { c.SendLine("") go c.ExpectEOF() time.Sleep(time.Millisecond) - c.Send("iAdd editor prompt tests\x1b") + c.Send("ccAdd editor prompt tests\x1b") c.SendLine(":wq!") }, "Add editor prompt tests\n", @@ -196,7 +192,7 @@ func TestEditorPrompt(t *testing.T) { c.SendLine("") go c.ExpectEOF() time.Sleep(time.Millisecond) - c.Send("iAdd editor prompt tests\x1b") + c.Send("ccAdd editor prompt tests\x1b") c.SendLine(":wq!") }, "Add editor prompt tests\n", @@ -230,7 +226,7 @@ func TestEditorPrompt(t *testing.T) { c.SendLine("") go c.ExpectEOF() time.Sleep(time.Millisecond) - c.Send("iAdd editor prompt tests\x1b") + c.Send("ccAdd editor prompt tests\x1b") c.SendLine(":wq!") }, "Add editor prompt tests\n", diff --git a/input.go b/input.go index 9c1a293a..a1abab14 100644 --- a/input.go +++ b/input.go @@ -1,6 +1,8 @@ package survey import ( + "errors" + "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" ) @@ -19,8 +21,8 @@ type Input struct { Default string Help string Suggest func(toComplete string) []string - typedAnswer string answer string + typedAnswer string options []core.OptionAnswer selectedIndex int showingHelp bool @@ -58,86 +60,90 @@ var InputQuestionTemplate = ` {{- 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 { +func (i *Input) onRune(config *PromptConfig) terminal.OnRuneFn { + return terminal.OnRuneFn(func(key rune, line []rune) ([]rune, bool, error) { + if i.options != nil && (key == terminal.KeyEnter || key == '\n') { + return []rune(i.answer), true, nil + } else if i.options != nil && key == terminal.KeyEscape { 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.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 { + i.answer = string(line) + i.typedAnswer = i.answer + options := i.Suggest(i.answer) 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 { + if len(options) == 0 { + return line, false, nil + } + i.answer = options[0] if len(options) == 1 { + i.typedAnswer = i.answer i.options = nil } else { i.options = core.OptionAnswerList(options) } + } else { + if i.options == nil { + return line, false, nil + } + + if key >= terminal.KeySpace { + i.answer += string(key) + } + i.typedAnswer = i.answer + + i.options = nil } - } else if key == terminal.KeyDelete || key == terminal.KeyBackspace { - if i.answer != "" { - runeAnswer := []rune(i.answer) - i.answer = string(runeAnswer[0 : len(runeAnswer)-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, - }, - ) + 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, + }, + ) + + if err == nil { + err = readLineAgain + } - return err != nil, err + return []rune(i.typedAnswer), true, err + }) } +var readLineAgain = errors.New("read line again") + func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { // render the template err := i.Render( InputQuestionTemplate, InputTemplateData{ - Input: *i, - Config: config, + Input: *i, + Config: config, + ShowHelp: i.showingHelp, }, ) if err != nil { @@ -155,27 +161,34 @@ func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { defer cursor.Show() // show the cursor when we're done } - // start waiting for input + var line []rune + for { - r, _, err := rr.ReadRune() - if err != nil { - return "", err + if i.options != nil { + line = []rune{} } - if r == terminal.KeyInterrupt { - return "", terminal.InterruptErr - } - if r == terminal.KeyEndTransmission { - break + + line, err = rr.ReadLineWithDefault(0, line, i.onRune(config)) + if err == readLineAgain { + continue } - b, err := i.OnChange(r, config) if err != nil { return "", err } - if b { - break - } + break + } + + i.answer = string(line) + // readline print an empty line, go up before we render the follow up + cursor.Up(1) + + // if we ran into the help string + if i.answer == config.HelpInput && i.Help != "" { + // show the help and prompt again + i.showingHelp = true + return i.Prompt(config) } // if the line is empty diff --git a/input_test.go b/input_test.go index 527af5c7..38b0c645 100644 --- a/input_test.go +++ b/input_test.go @@ -102,15 +102,6 @@ func TestInputRender(t *testing.T) { 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 { @@ -377,6 +368,54 @@ func TestInputPrompt(t *testing.T) { }, "special answer", }, + { + "Test Input prompt must allow moving cursor using right and left arrows", + &Input{Message: "Filename to save:"}, + func(c *expect.Console) { + c.ExpectString("Filename to save:") + c.Send("essay.txt") + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send("_final") + c.Send(string(terminal.KeyArrowRight)) + c.Send(string(terminal.KeyArrowRight)) + c.Send(string(terminal.KeyArrowRight)) + c.Send(string(terminal.KeyArrowRight)) + c.Send(string(terminal.KeyBackspace)) + c.Send(string(terminal.KeyBackspace)) + c.Send(string(terminal.KeyBackspace)) + c.Send("md") + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.SendLine("2") + c.ExpectEOF() + }, + "essay_final2.md", + }, + { + "Test Input prompt must allow moving cursor using right and left arrows, even after suggestions", + &Input{Message: "Filename to save:", Suggest: func(string) []string { return []string{".txt", ".csv", ".go"} }}, + func(c *expect.Console) { + c.ExpectString("Filename to save:") + c.Send(string(terminal.KeyTab)) + c.ExpectString(".txt") + c.ExpectString(".csv") + c.ExpectString(".go") + c.Send(string(terminal.KeyTab)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send(string(terminal.KeyArrowLeft)) + c.Send("newtable") + c.SendLine("") + c.ExpectEOF() + }, + "newtable.csv", + }, } for _, test := range tests { diff --git a/survey_test.go b/survey_test.go index 2d6b636e..0bd1f864 100644 --- a/survey_test.go +++ b/survey_test.go @@ -206,7 +206,7 @@ func TestAsk(t *testing.T) { c.ExpectString("Edit git commit message [Enter to launch editor]") c.SendLine("") time.Sleep(time.Millisecond) - c.Send("iAdd editor prompt tests\x1b") + c.Send("ccAdd editor prompt tests\x1b") c.SendLine(":wq!") // Editor validated @@ -221,7 +221,7 @@ func TestAsk(t *testing.T) { c.SendLine("") time.Sleep(time.Millisecond) c.ExpectString("first try") - c.Send("ccAdd editor prompt tests\x1b") + c.Send("ccAdd editor prompt tests, but validated\x1b") c.SendLine(":wq!") // Input @@ -253,7 +253,7 @@ func TestAsk(t *testing.T) { map[string]interface{}{ "pizza": true, "commit-message": "Add editor prompt tests\n", - "commit-message-validated": "Add editor prompt tests\n", + "commit-message-validated": "Add editor prompt tests, but validated\n", "name": "Johnny Appleseed", /* TODO "day": []string{"Monday", "Wednesday"}, diff --git a/terminal/runereader.go b/terminal/runereader.go index 25766888..94c915c5 100644 --- a/terminal/runereader.go +++ b/terminal/runereader.go @@ -31,7 +31,13 @@ func (rr *RuneReader) printChar(char rune, mask rune) { } } -func (rr *RuneReader) ReadLine(mask rune) ([]rune, error) { +type OnRuneFn func(rune, []rune) ([]rune, bool, error) + +func (rr *RuneReader) ReadLine(mask rune, onRunes ...OnRuneFn) ([]rune, error) { + return rr.ReadLineWithDefault(mask, []rune{}, onRunes...) +} + +func (rr *RuneReader) ReadLineWithDefault(mask rune, d []rune, onRunes ...OnRuneFn) ([]rune, error) { line := []rune{} // we only care about horizontal displacements from the origin so start counting at 0 index := 0 @@ -41,6 +47,15 @@ func (rr *RuneReader) ReadLine(mask rune) ([]rune, error) { Out: rr.stdio.Out, } + onRune := func(r rune, line []rune) ([]rune, bool, error) { + return line, false, nil + } + + // if the user pressed a key the caller was interested in capturing + if len(onRunes) > 0 { + onRune = onRunes[0] + } + // we get the terminal width and height (if resized after this point the property might become invalid) terminalSize, _ := cursor.Size(rr.Buffer()) // we set the current location of the cursor once @@ -63,6 +78,15 @@ func (rr *RuneReader) ReadLine(mask rune) ([]rune, error) { } } + if len(d) > 0 { + index = len(d) + fmt.Fprint(rr.stdio.Out, string(d)) + line = d + for range d { + increment() + } + } + for { // wait for some input r, _, err := rr.ReadRune() @@ -70,6 +94,10 @@ func (rr *RuneReader) ReadLine(mask rune) ([]rune, error) { return line, err } + if l, stop, err := onRune(r, line); stop || err != nil { + return l, err + } + // if the user pressed enter or some other newline/termination like ctrl+d if r == '\r' || r == '\n' || r == KeyEndTransmission { // delete what's printed out on the console screen (cleanup) @@ -342,4 +370,4 @@ func runeWidth(r rune) int { return 2 } return 1 -} \ No newline at end of file +}