Repository: AlecAivazis/survey
Pull request #420 (2022-05-12 13:17:06)

Ensure accurate selected index returned from Select
Fixes https://github.com/AlecAivazis/survey/issues/412 which describes the case where the selected index would be mis-reported when some options happen to share identical value. Since the Select component internally tracks the `selectedIndex`, just use that value instead of trying to determine it by comparing strings.

@AlecAivazis In my attempts to clean up default value handling here, I fear I have introduced backwards-incompatible changes. I could restore the previous behavior, sure, but I wanted to ask if it's also worth "fixing" the default value handling. Namely:

- Before, when a string value was passed, for example `Default: "moo"`, this default was used in the result when the user immediately pressed <kbd>Enter</kbd> (i.e. without otherwise interacting with the Select component) **even if the value `moo` did not exist among Options**. Now, **the prompt will error out** noting that the default value must be present among Options, mainly so that it can be visually pre-selected when rendering the widget.

- Before, when an invalid int value was passed, for example `Default: 1000` when there are fewer than 1000 Options, a panic would happen when the user immediately pressed <kbd>Enter</kbd>, _but not otherwise_. Now, the prompt will **immediately error out** noting that the Default value is invalid since it's out of bounds.

So, what I changed here felt like fixing bugs to me, but I can imagine that some users might be relying on these features, for example having a Default that's not among Options so they can detect when the user wanted to skip a Select. I'm not sure if those features were worth keeping, though. For example, consider this prompt:

```
? Choose a country:  [Use arrows to move, type to filter]
> Afghanistan
  Åland Islands
  Albania
```

If I press <kbd>Enter</kbd> here, I would expect that "Afghanistan" got selected. However, if there was a Default value such as "moo", that string will be used instead of "Afghanistan", which I find counter-intuitive. I would rather break this feature than have users rely on it.

What are your thoughts?

AlecAivazis: Oh, man. This is one of those super tough situations. I agree with you that these are really bug fixes but it seems very likely that someone out there is relying on the silent failure or doesn't know they have an invalid default value. 

I'm leaning more towards keeping this under `v2` and we should just make sure its very clear what happened in the release notes.  

Patch:
diff --git a/README.md b/README.md
index 92997f6..ba75b32 100644
--- a/README.md
+++ b/README.md
@@ -193,6 +193,28 @@ prompt := &survey.MultiSelect{..., PageSize: 10}
 survey.AskOne(prompt, &days, survey.WithPageSize(10))
 ```
 
+#### Select options description
+
+The optional description text can be used to add extra information to each option listed in the select prompt:
+
+```golang
+color := ""
+prompt := &survey.Select{
+    Message: "Choose a color:",
+    Options: []string{"red", "blue", "green"},
+    Description: func(value string, index int) string {
+        if value == "red" {
+            return "My favorite color"
+        }
+        return ""
+    },
+}
+survey.AskOne(prompt, &color)
+
+// Assuming that the user chose "red - My favorite color":
+fmt.Println(color) //=> "red"
+```
+
 ### MultiSelect
 
 ![Example](img/multi-select-all-none.gif)
@@ -335,6 +357,39 @@ All of the prompts have a `Help` field which can be defined to provide more info
 }
 ```
 
+## Removing the "Select All" and "Select None" options
+
+By default, users can select all of the multi-select options using the right arrow key. To prevent users from being able to do this (and remove the `<right> to all` message from the prompt), use the option `WithRemoveSelectAll`:
+
+```golang
+import (
+    "github.com/AlecAivazis/survey/v2"
+)
+
+number := ""
+prompt := &survey.Input{
+    Message: "This question has the select all option removed",
+}
+
+survey.AskOne(prompt, &number, survey.WithRemoveSelectAll())
+```
+
+Also by default, users can use the left arrow key to unselect all of the options. To prevent users from being able to do this (and remove the `<left> to none` message from the prompt), use the option `WithRemoveSelectNone`:
+
+```golang
+import (
+    "github.com/AlecAivazis/survey/v2"
+)
+
+number := ""
+prompt := &survey.Input{
+    Message: "This question has the select all option removed",
+}
+
+survey.AskOne(prompt, &number, survey.WithRemoveSelectNone())
+```
+
+
 ### Changing the input rune
 
 In some situations, `?` is a perfectly valid response. To handle this, you can change the rune that survey
diff --git a/examples/select_description.go b/examples/select_description.go
new file mode 100644
index 0000000..bc1568a
--- /dev/null
+++ b/examples/select_description.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+	"fmt"
+
+	"github.com/AlecAivazis/survey/v2"
+)
+
+type Meal struct {
+	Title   string
+	Comment string
+}
+
+func main() {
+	var meals = []Meal{
+		{Title: "Bread", Comment: "Contains gluten"},
+		{Title: "Eggs", Comment: "Free-range"},
+		{Title: "Apple", Comment: ""},
+		{Title: "Burger", Comment: "Veggie patties available"},
+	}
+
+	titles := make([]string, len(meals))
+	for i, m := range meals {
+		titles[i] = m.Title
+	}
+	var qs = &survey.Select{
+		Message: "Choose a meal:",
+		Options: titles,
+		Description: func(value string, index int) string {
+			return meals[index].Comment
+		},
+	}
+
+	answerIndex := 0
+
+	// ask the question
+	err := survey.AskOne(qs, &answerIndex)
+
+	if err != nil {
+		fmt.Println(err.Error())
+		return
+	}
+
+	meal := meals[answerIndex]
+	// print the answers
+	fmt.Printf("you picked %s, nice choice.\n", meal.Title)
+}
diff --git a/multiselect.go b/multiselect.go
index 3251ea1..3eb8342 100644
--- a/multiselect.go
+++ b/multiselect.go
@@ -71,7 +71,7 @@ var MultiSelectQuestionTemplate = `
 {{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
 {{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
 {{- else }}
-	{{- "  "}}{{- color "cyan"}}[Use arrows to move, space to select, <right> to all, <left> to none, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
+	{{- "  "}}{{- color "cyan"}}[Use arrows to move, space to select,{{- if not .Config.RemoveSelectAll }} <right> to all,{{end}}{{- if not .Config.RemoveSelectNone }} <left> to none,{{end}} type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
   {{- "\n"}}
   {{- range $ix, $option := .PageEntries}}
     {{- template "option" $.IterateOption $ix $option}}
@@ -134,14 +134,14 @@ func (m *MultiSelect) OnChange(key rune, config *PromptConfig) {
 	} else if key >= terminal.KeySpace {
 		m.filter += string(key)
 		m.VimMode = false
-	} else if key == terminal.KeyArrowRight {
+	} else if !config.RemoveSelectAll && key == terminal.KeyArrowRight {
 		for _, v := range options {
 			m.checked[v.Index] = true
 		}
 		if !config.KeepFilter {
 			m.filter = ""
 		}
-	} else if key == terminal.KeyArrowLeft {
+	} else if !config.RemoveSelectNone && key == terminal.KeyArrowLeft {
 		for _, v := range options {
 			m.checked[v.Index] = false
 		}
diff --git a/select.go b/select.go
index 5c4996f..1210122 100644
--- a/select.go
+++ b/select.go
@@ -2,6 +2,7 @@ package survey
 
 import (
 	"errors"
+	"fmt"
 
 	"github.com/AlecAivazis/survey/v2/core"
 	"github.com/AlecAivazis/survey/v2/terminal"
@@ -28,9 +29,9 @@ type Select struct {
 	VimMode       bool
 	FilterMessage string
 	Filter        func(filter string, value string, index int) bool
+	Description   func(value string, index int) string
 	filter        string
 	selectedIndex int
-	useDefault    bool
 	showingHelp   bool
 }
 
@@ -42,6 +43,7 @@ type SelectTemplateData struct {
 	Answer        string
 	ShowAnswer    bool
 	ShowHelp      bool
+	Description   func(value string, index int) string
 	Config        *PromptConfig
 
 	// These fields are used when rendering an individual option
@@ -57,10 +59,17 @@ func (s SelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interfa
 	return copy
 }
 
+func (s SelectTemplateData) GetDescription(opt core.OptionAnswer) string {
+	if s.Description == nil {
+		return ""
+	}
+	return s.Description(opt.Value, opt.Index)
+}
+
 var SelectQuestionTemplate = `
 {{- define "option"}}
     {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}}  {{end}}
-    {{- .CurrentOpt.Value}}
+    {{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{end}}
     {{- color "reset"}}
 {{end}}
 {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
@@ -94,8 +103,6 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool {
 
 		// if the user pressed the up arrow or 'k' to emulate vim
 	} else if (key == terminal.KeyArrowUp || (s.VimMode && key == 'k')) && len(options) > 0 {
-		s.useDefault = false
-
 		// if we are at the top of the list
 		if s.selectedIndex == 0 {
 			// start from the button
@@ -107,7 +114,6 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool {
 
 		// if the user pressed down or 'j' to emulate vim
 	} 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 {
 			// start from the top
@@ -138,8 +144,6 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool {
 		s.filter += string(key)
 		// make sure vim mode is disabled
 		s.VimMode = false
-		// make sure that we use the current value in the filtered list
-		s.useDefault = false
 	}
 
 	s.FilterMessage = ""
@@ -171,6 +175,7 @@ func (s *Select) OnChange(key rune, config *PromptConfig) bool {
 		Select:        *s,
 		SelectedIndex: idx,
 		ShowHelp:      s.showingHelp,
+		Description:   s.Description,
 		PageEntries:   opts,
 		Config:        config,
 	}
@@ -197,7 +202,6 @@ func (s *Select) filterOptions(config *PromptConfig) []core.OptionAnswer {
 		filter = config.Filter
 	}
 
-	//
 	for i, opt := range s.Options {
 		// i the filter says to include the option
 		if filter(s.filter, opt, i) {
@@ -219,23 +223,29 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
 		return "", errors.New("please provide options to select from")
 	}
 
-	// start off with the first option selected
-	sel := 0
-	// if there is a default
-	if s.Default != "" {
-		// find the choice
-		for i, opt := range s.Options {
-			// if the option corresponds to the default
-			if opt == s.Default {
-				// we found our initial value
-				sel = i
-				// stop looking
-				break
+	s.selectedIndex = 0
+	if s.Default != nil {
+		switch defaultValue := s.Default.(type) {
+		case string:
+			var found bool
+			for i, opt := range s.Options {
+				if opt == defaultValue {
+					s.selectedIndex = i
+					found = true
+				}
 			}
+			if !found {
+				return "", fmt.Errorf("default value %q not found in options", defaultValue)
+			}
+		case int:
+			if defaultValue >= len(s.Options) {
+				return "", fmt.Errorf("default index %d exceeds the number of options", defaultValue)
+			}
+			s.selectedIndex = defaultValue
+		default:
+			return "", errors.New("default value of select must be an int or string")
 		}
 	}
-	// save the selected index
-	s.selectedIndex = sel
 
 	// figure out the page size
 	pageSize := s.PageSize
@@ -246,7 +256,7 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
 	}
 
 	// figure out the options and index to render
-	opts, idx := paginate(pageSize, core.OptionAnswerList(s.Options), sel)
+	opts, idx := paginate(pageSize, core.OptionAnswerList(s.Options), s.selectedIndex)
 
 	cursor := s.NewCursor()
 	cursor.Save()          // for proper cursor placement during selection
@@ -257,6 +267,7 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
 	tmplData := SelectTemplateData{
 		Select:        *s,
 		SelectedIndex: idx,
+		Description:   s.Description,
 		ShowHelp:      s.showingHelp,
 		PageEntries:   opts,
 		Config:        config,
@@ -268,9 +279,6 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
 		return "", err
 	}
 
-	// by default, use the default value
-	s.useDefault = true
-
 	rr := s.NewRuneReader()
 	_ = rr.SetTermMode()
 	defer func() {
@@ -293,45 +301,16 @@ func (s *Select) Prompt(config *PromptConfig) (interface{}, error) {
 			break
 		}
 	}
+
 	options := s.filterOptions(config)
 	s.filter = ""
 	s.FilterMessage = ""
 
-	// the index to report
-	var val string
-	// if we are supposed to use the default value
-	if s.useDefault || s.selectedIndex >= len(options) {
-		// if there is a default value
-		if s.Default != nil {
-			// if the default is a string
-			if defaultString, ok := s.Default.(string); ok {
-				// use the default value
-				val = defaultString
-				// the default value could also be an interpret which is interpretted as the index
-			} else if defaultIndex, ok := s.Default.(int); ok {
-				val = s.Options[defaultIndex]
-			} else {
-				return val, errors.New("default value of select must be an int or string")
-			}
-		} else if len(options) > 0 {
-			// there is no default value so use the first
-			val = options[0].Value
-		}
-		// otherwise the selected index points to the value
-	} else if s.selectedIndex < len(options) {
-		// the
-		val = options[s.selectedIndex].Value
-	}
-
-	// now that we have the value lets go hunt down the right index to return
-	idx = -1
-	for i, optionValue := range s.Options {
-		if optionValue == val {
-			idx = i
-		}
+	if s.selectedIndex < len(options) {
+		return options[s.selectedIndex], err
 	}
 
-	return core.OptionAnswer{Value: val, Index: idx}, err
+	return options[0], err
 }
 
 func (s *Select) Cleanup(config *PromptConfig, val interface{}) error {
@@ -340,10 +319,11 @@ func (s *Select) Cleanup(config *PromptConfig, val interface{}) error {
 	return s.Render(
 		SelectQuestionTemplate,
 		SelectTemplateData{
-			Select:     *s,
-			Answer:     val.(core.OptionAnswer).Value,
-			ShowAnswer: true,
-			Config:     config,
+			Select:      *s,
+			Answer:      val.(core.OptionAnswer).Value,
+			ShowAnswer:  true,
+			Description: s.Description,
+			Config:      config,
 		},
 	)
 }
diff --git a/survey.go b/survey.go
index 95136eb..3e03b69 100644
--- a/survey.go
+++ b/survey.go
@@ -56,8 +56,10 @@ func defaultAskOptions() *AskOptions {
 				// include this option if it matches
 				return strings.Contains(strings.ToLower(value), filter)
 			},
-			KeepFilter: false,
-			ShowCursor: false,
+			KeepFilter:       false,
+			ShowCursor:       false,
+			RemoveSelectAll:  false,
+			RemoveSelectNone: false,
 		},
 	}
 }
@@ -111,13 +113,15 @@ type Question struct {
 
 // PromptConfig holds the global configuration for a prompt
 type PromptConfig struct {
-	PageSize     int
-	Icons        IconSet
-	HelpInput    string
-	SuggestInput string
-	Filter       func(filter string, option string, index int) bool
-	KeepFilter   bool
-	ShowCursor   bool
+	PageSize         int
+	Icons            IconSet
+	HelpInput        string
+	SuggestInput     string
+	Filter           func(filter string, option string, index int) bool
+	KeepFilter       bool
+	ShowCursor       bool
+	RemoveSelectAll  bool
+	RemoveSelectNone bool
 }
 
 // Prompt is the primary interface for the objects that can take user input
@@ -175,6 +179,22 @@ func WithKeepFilter(KeepFilter bool) AskOpt {
 	}
 }
 
+// WithRemoveSelectAll remove the select all option in Multiselect
+func WithRemoveSelectAll() AskOpt {
+	return func(options *AskOptions) error {
+		options.PromptConfig.RemoveSelectAll = true
+		return nil
+	}
+}
+
+// WithRemoveSelectNone remove the select none/unselect all in Multiselect
+func WithRemoveSelectNone() AskOpt {
+	return func(options *AskOptions) error {
+		options.PromptConfig.RemoveSelectNone = true
+		return nil
+	}
+}
+
 // WithValidator specifies a validator to use while prompting the user
 func WithValidator(v Validator) AskOpt {
 	return func(options *AskOptions) error {


Test patch:
diff --git a/multiselect_test.go b/multiselect_test.go
index 41e8c2b..3d6b2ed 100644
--- a/multiselect_test.go
+++ b/multiselect_test.go
@@ -579,3 +579,69 @@ func TestMultiSelectPromptKeepFilter(t *testing.T) {
 		})
 	}
 }
+
+func TestMultiSelectPromptRemoveSelectAll(t *testing.T) {
+	tests := []PromptTest{
+		{
+			"multi select with remove select all option",
+			&MultiSelect{
+				Message: "What color do you prefer:",
+				Options: []string{"green", "red", "light-green", "blue", "black", "yellow", "purple"},
+			},
+			func(c expectConsole) {
+				c.ExpectString("What color do you prefer:  [Use arrows to move, space to select, <left> to none, type to filter]")
+				// Select the first option "green"
+				c.Send(" ")
+
+				// attempt to select all (this shouldn't do anything)
+				c.Send(string(terminal.KeyArrowRight))
+
+				// end the session
+				c.SendLine("")
+				c.ExpectEOF()
+			},
+			[]core.OptionAnswer{ // we should only have one option selected, not all of them
+				{Value: "green", Index: 0},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			RunPromptTestRemoveSelectAll(t, test)
+		})
+	}
+}
+
+func TestMultiSelectPromptRemoveSelectNone(t *testing.T) {
+	tests := []PromptTest{
+		{
+			"multi select with remove select none option",
+			&MultiSelect{
+				Message: "What color do you prefer:",
+				Options: []string{"green", "red", "light-green", "blue", "black", "yellow", "purple"},
+			},
+			func(c expectConsole) {
+				c.ExpectString("What color do you prefer:  [Use arrows to move, space to select, <right> to all, type to filter]")
+				// Select the first option "green"
+				c.Send(" ")
+
+				// attempt to unselect all (this shouldn't do anything)
+				c.Send(string(terminal.KeyArrowLeft))
+
+				// end the session
+				c.SendLine("")
+				c.ExpectEOF()
+			},
+			[]core.OptionAnswer{ // we should only have one option selected, not all of them
+				{Value: "green", Index: 0},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			RunPromptTestRemoveSelectNone(t, test)
+		})
+	}
+}
diff --git a/survey_test.go b/survey_test.go
index d0d6230..ff8a158 100644
--- a/survey_test.go
+++ b/survey_test.go
@@ -97,6 +97,38 @@ func RunPromptTestKeepFilter(t *testing.T, test PromptTest) {
 	require.Equal(t, test.expected, answer)
 }
 
+func RunPromptTestRemoveSelectAll(t *testing.T, test PromptTest) {
+	t.Helper()
+	var answer interface{}
+	RunTest(t, test.procedure, func(stdio terminal.Stdio) error {
+		var err error
+		if p, ok := test.prompt.(wantsStdio); ok {
+			p.WithStdio(stdio)
+		}
+		config := defaultPromptConfig()
+		config.RemoveSelectAll = true
+		answer, err = test.prompt.Prompt(config)
+		return err
+	})
+	require.Equal(t, test.expected, answer)
+}
+
+func RunPromptTestRemoveSelectNone(t *testing.T, test PromptTest) {
+	t.Helper()
+	var answer interface{}
+	RunTest(t, test.procedure, func(stdio terminal.Stdio) error {
+		var err error
+		if p, ok := test.prompt.(wantsStdio); ok {
+			p.WithStdio(stdio)
+		}
+		config := defaultPromptConfig()
+		config.RemoveSelectNone = true
+		answer, err = test.prompt.Prompt(config)
+		return err
+	})
+	require.Equal(t, test.expected, answer)
+}
+
 func TestPagination_tooFew(t *testing.T) {
 	// a small list of options
 	choices := core.OptionAnswerList([]string{"choice1", "choice2", "choice3"})