Repository: AlecAivazis/survey
Pull request #361 (2021-06-28 18:02:31)

(fix): problems with input navegation
- first pass at input using RuneReader.ReadLine with suggestions
- fix newline from readline
- better callback name


lucassabreu: @AlecAivazis i think it's done, but there is a error on Ubuntu, you know what it is?
AlecAivazis: Looks like the test on `survey_test.go:249` is hanging
lucassabreu: fixed the hanging looks like some problem with using `i` on `vi` in the actions... changed all to `cc`.

it also fixed the compatibility problem with my vim when i did that.
AlecAivazis: Nice!! I'll find some time today to test this 👍 

Patch:
diff --git a/_tasks.hcl b/_tasks.hcl
index aaa4768..0eefcb5 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/input.go b/input.go
index 9c1a293..a1abab1 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/terminal/runereader.go b/terminal/runereader.go
index 2576688..94c915c 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
+}


Test patch:
diff --git a/editor_test.go b/editor_test.go
index 97f1144..5ad9d62 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_test.go b/input_test.go
index 527af5c..38b0c64 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 2d6b636..0bd1f86 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"},