diff --git a/README.md b/README.md index fa1e2166..f46833a2 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,32 @@ prompt := &survey.Input{ } survey.AskOne(prompt, &file) ``` +#### Default + +Input with default demonstration + +```golang +library := "" +prompt := &survey.Input{ + Message: "My favorite go library:", + Default: "survey" +} +survey.AskOne(prompt, &library) +``` + +#### Prefill + +Input with prefill demonstration + +```golang +library := "" +prompt := &survey.Input{ + Message: "My favorite go library:", + Prefill: true, + Default: "survey", +} +survey.AskOne(prompt, &library) +``` ### Multiline @@ -244,6 +270,21 @@ prompt := &survey.MultiSelect{..., PageSize: 10} survey.AskOne(prompt, &days, survey.WithPageSize(10)) ``` +### Slider + +Slider input demonstration + +```golang +pies := 0 +prompt := &survey.Slider{ + Message: "How many pies do you want?", + Max: 50, +} +survey.AskOne(prompt, &pies) +``` + +A slider allow to retrieve an int value from the user. It can be configured using `Min` and `Max` value. The default size is a 26 characters long slider, this can be configured using `MaxSize`. + ### Editor Launches the user's preferred editor (defined by the \$VISUAL or \$EDITOR environment variables) on a @@ -318,6 +359,19 @@ However the user can prevent this from happening and keep the filter active for survey.AskOne(prompt, &color, survey.WithKeepFilter(true)) ``` +## Disabling the filter + +By default the filter is always enabled. To disable it, use the `WithDisableFilter` option + +```golang +&Select{ + Message: "Choose a color:", + Options: []string{"light-green", "green", "dark-green", "red"}, +} + +survey.AskOne(prompt, &color, survey.WithDisableFilter()) +``` + ## Validation Validating individual responses for a particular question can be done by defining a diff --git a/confirm.go b/confirm.go index 1c23fb4d..8ae56281 100644 --- a/confirm.go +++ b/confirm.go @@ -30,7 +30,7 @@ var ConfirmQuestionTemplate = ` {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} {{- else }} {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}} - {{- color "white"}}{{if .Default}}(Y/n) {{else}}(y/N) {{end}}{{color "reset"}} + {{- color "gray"}}{{if .Default}}(Y/n) {{else}}(y/N) {{end}}{{color "reset"}} {{- end}}` // the regex for answers diff --git a/core/template.go b/core/template.go index ca9aec09..e9b3da39 100644 --- a/core/template.go +++ b/core/template.go @@ -15,10 +15,22 @@ var DisableColor = false var TemplateFuncsWithColor = map[string]interface{}{ // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format - "color": ansi.ColorCode, + "color": color, "spaces": spaces, } +func color(style string) string { + switch style { + case "gray": + // Fails on windows, only affects defaults + if env256ColorSupported() { + return ansi.ColorCode("8") + } + return ansi.ColorCode("default") + default: + return ansi.ColorCode(style) + } +} func spaces(selectorText string) string { length := 0 for _, s := range selectorText { @@ -52,6 +64,25 @@ func envColorForced() bool { return ok && val != "0" } +// Could probably be improved +// env256ColorSupported returns if terminal supports ansi 256 colors - taken from github code: https://github.com/cli/go-gh/blob/trunk/pkg/term/env.go +func env256ColorSupported() bool { + return envTrueColorSupported() || + strings.Contains(os.Getenv("TERM"), "256") || + strings.Contains(os.Getenv("COLORTERM"), "256") +} + +// envTrueColorSupported returns if terminal supports true color - taken from github code: https://github.com/cli/go-gh/blob/trunk/pkg/term/env.go +func envTrueColorSupported() bool { + term := os.Getenv("TERM") + colorterm := os.Getenv("COLORTERM") + + return strings.Contains(term, "24bit") || + strings.Contains(term, "truecolor") || + strings.Contains(colorterm, "24bit") || + strings.Contains(colorterm, "truecolor") +} + // RunTemplate returns two formatted strings given a template and // the data it requires. The first string returned is generated for // user-facing output and may or may not contain ANSI escape codes diff --git a/editor.go b/editor.go index 396a6673..9f451ad2 100644 --- a/editor.go +++ b/editor.go @@ -53,7 +53,7 @@ var EditorQuestionTemplate = ` {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} {{- else }} {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}} - {{- if and .Default (not .HideDefault)}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} + {{- if and .Default (not .HideDefault)}}{{color "gray"}}({{.Default}}) {{color "reset"}}{{end}} {{- color "cyan"}}[Enter to launch editor] {{color "reset"}} {{- end}}` diff --git a/img/input-with-default.gif b/img/input-with-default.gif new file mode 100644 index 00000000..2d0f57dd Binary files /dev/null and b/img/input-with-default.gif differ diff --git a/img/input-with-prefill.gif b/img/input-with-prefill.gif new file mode 100644 index 00000000..05b30b17 Binary files /dev/null and b/img/input-with-prefill.gif differ diff --git a/img/slider.gif b/img/slider.gif new file mode 100644 index 00000000..76f84560 Binary files /dev/null and b/img/slider.gif differ diff --git a/input.go b/input.go index 0ad04776..c5723f29 100644 --- a/input.go +++ b/input.go @@ -19,6 +19,7 @@ type Input struct { Renderer Message string Default string + Prefill bool Help string Suggest func(toComplete string) []string answer string @@ -59,7 +60,7 @@ var InputQuestionTemplate = ` {{- 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}} + {{- if and .Default (not .Prefill)}}{{color "gray"}}({{.Default}}) {{color "reset"}}{{end}} {{- end}}` func (i *Input) onRune(config *PromptConfig) terminal.OnRuneFn { @@ -165,6 +166,9 @@ func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { } var line []rune + if i.Prefill { + line = []rune(i.Default) + } for { if i.options != nil { @@ -195,7 +199,7 @@ func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { } // if the line is empty - if len(i.answer) == 0 { + if len(i.answer) == 0 && !i.Prefill { // use the default value return i.Default, err } diff --git a/input_test.go b/input_test.go index 4e3e0c61..44268e04 100644 --- a/input_test.go +++ b/input_test.go @@ -102,6 +102,16 @@ func TestInputRender(t *testing.T) { defaultIcons().Question.Text, defaultPromptConfig().Icons.SelectFocus.Text, ), }, + { + "Test Input question output with default prefilled", + Input{Message: "What is your favorite month:", Prefill: true, Default: "January"}, + InputTemplateData{}, + fmt.Sprintf( + // It is not rendered, the reader has tha value by default + "%s What is your favorite month: ", + defaultIcons().Question.Text, + ), + }, } for _, test := range tests { @@ -161,6 +171,37 @@ func TestInputPrompt(t *testing.T) { }, "Johnny Appleseed", }, + { + "Test Input prompt interaction with default prefilled", + &Input{ + Message: "What is your name?", + Default: "Johnny Appleseed", + Prefill: true, + }, + nil, + func(c expectConsole) { + c.ExpectString("What is your name?") + c.SendLine("") + c.ExpectEOF() + }, + "Johnny Appleseed", + }, + { + "Test Input prompt interaction with default prefilled being modified", + &Input{ + Message: "What is your name?", + Default: "Johnny Appleseed", + Prefill: true, + }, + nil, + func(c expectConsole) { + c.ExpectString("What is your name?") + c.Send(string(terminal.KeyDelete)) + c.SendLine("") + c.ExpectEOF() + }, + "Johnny Applesee", + }, { "Test Input prompt interaction overriding default", &Input{ diff --git a/multiline.go b/multiline.go index c56b78b0..9c24a59e 100644 --- a/multiline.go +++ b/multiline.go @@ -31,7 +31,7 @@ var MultilineQuestionTemplate = ` {{- "\n"}}{{color "cyan"}}{{.Answer}}{{color "reset"}} {{- if .Answer }}{{ "\n" }}{{ end }} {{- else }} - {{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} + {{- if .Default}}{{color "gray"}}({{.Default}}) {{color "reset"}}{{end}} {{- color "cyan"}}[Enter 2 empty lines to finish]{{color "reset"}} {{- end}}` diff --git a/slider.go b/slider.go new file mode 100644 index 00000000..61497b01 --- /dev/null +++ b/slider.go @@ -0,0 +1,203 @@ +package survey + +import ( + "errors" + "github.com/Iilun/survey/v2/terminal" +) + +/* +Slider is a prompt that presents a slider that can be manipulated +using the arrow keys and enter. Response type is an int. + + var count int + prompt := &survey.Slider{ + Message: "Choose a number:", + Max: 50 + } + survey.AskOne(prompt, &count) +*/ +type Slider struct { + Renderer + Message string + Default int + Help string + Min int + Max int + ChangeInterval int + MaxSize int + selectedValue int + showingHelp bool +} + +// SliderTemplateData is the data available to the templates when processing +type SliderTemplateData struct { + Slider + SelectedValue int + ShowAnswer bool + ShowHelp bool + SliderContent []Icon + Config *PromptConfig +} + +var SliderQuestionTemplate = ` +{{- define "sliderValue"}}{{- color $.Format}}{{ $.Text}}{{- color "reset"}}{{end}} +{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{- "\n"}}{{end}} +{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} +{{- color "default+hb"}}{{ .Message }} {{color "reset"}} +{{- if .ShowAnswer}} + {{- color "cyan"}}{{.SelectedValue}}{{color "reset"}}{{"\n"}} +{{- else}} + {{- color "cyan"}}[Use side arrows to alter value by 1, vertical arrows to alter value by {{ .ChangeInterval }}{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}{{- "\n"}} + {{- " "}}{{- range $_, $option := .SliderContent}}{{- template "sliderValue" $option}}{{- end}} {{ .SelectedValue }}{{- "\n"}} +{{- end}}` + +func (s *Slider) changeValue(change int) { + s.selectedValue += change + if change > 0 && s.selectedValue > s.Max { + s.selectedValue = s.Max + } else if s.selectedValue < s.Min { + s.selectedValue = s.Min + } + +} + +// OnChange is called on every keypress. +func (s *Slider) OnChange(key rune, config *PromptConfig) bool { + + // if the user pressed the enter key and the index is a valid option + if key == terminal.KeyEnter || key == '\n' { + return true + + // if the user pressed the up arrow + } else if key == terminal.KeyArrowUp && s.selectedValue < s.Max { + s.changeValue(s.ChangeInterval) + // if the user pressed down + } else if key == terminal.KeyArrowDown && s.selectedValue > s.Min { + s.changeValue(-s.ChangeInterval) + // only show the help message if we have one + } else if string(key) == config.HelpInput && s.Help != "" { + s.showingHelp = true + // if the user wants to decrease the value by one + } else if key == terminal.KeyArrowLeft { + s.changeValue(-1) + // if the user wants to increase the value by one + } else if key == terminal.KeyArrowRight { + s.changeValue(1) + } + + tmplData := SliderTemplateData{ + Slider: *s, + SelectedValue: s.selectedValue, + ShowHelp: s.showingHelp, + Config: config, + SliderContent: s.computeSliderContent(config), + } + + // render the options + _ = s.Render(SliderQuestionTemplate, tmplData) + + // keep prompting + return false +} + +type SliderContent struct { + Format string + Value string +} + +func (s *Slider) computeSliderContent(config *PromptConfig) []Icon { + var output []Icon + + // Computing how much one character represents + interval := (s.Max - s.Min) / s.MaxSize + if interval <= 0 { + interval = 1 + } + for i := s.Min; i <= s.Max; i += interval { + if s.selectedValue >= i && s.selectedValue < i+interval { + // Our selected value is in this range + output = append(output, config.Icons.SliderCursor) + } else { + output = append(output, config.Icons.SliderFiller) + } + } + return output +} + +func (s *Slider) Prompt(config *PromptConfig) (interface{}, error) { + // if configuration is incoherent + if s.Max <= s.Min { + // we failed + return "", errors.New("please provide an interval") + } + // This is only so that user changing min max do not always have to change default accordingly + if s.Default == 0 && (s.Min > 0 || s.Max <= 0) { + s.Default = s.Min + } + if s.Default > s.Max || s.Default < s.Min { + // we failed + return "", errors.New("default value outside range") + } + s.selectedValue = s.Default + if s.ChangeInterval == 0 { + s.ChangeInterval = 10 + } + if s.MaxSize == 0 { + s.MaxSize = 25 + } + + cursor := s.NewCursor() + cursor.Hide() // hide the cursor + defer cursor.Show() // show the cursor when we're done + + tmplData := SliderTemplateData{ + Slider: *s, + SelectedValue: s.selectedValue, + ShowHelp: s.showingHelp, + Config: config, + SliderContent: s.computeSliderContent(config), + } + + // ask the question + err := s.Render(SliderQuestionTemplate, tmplData) + if err != nil { + return "", err + } + + rr := s.NewRuneReader() + _ = rr.SetTermMode() + defer func() { + _ = rr.RestoreTermMode() + }() + + // start waiting for input + for { + r, _, err := rr.ReadRune() + if err != nil { + return "", err + } + if r == terminal.KeyInterrupt { + return "", terminal.InterruptErr + } + if r == terminal.KeyEndTransmission { + break + } + if s.OnChange(r, config) { + break + } + } + return s.selectedValue, err +} + +func (s *Slider) Cleanup(config *PromptConfig, val interface{}) error { + return s.Render( + SliderQuestionTemplate, + SliderTemplateData{ + Slider: *s, + SelectedValue: s.selectedValue, + ShowHelp: false, + ShowAnswer: true, + Config: config, + }, + ) +} diff --git a/slider_test.go b/slider_test.go new file mode 100644 index 00000000..ed6cb046 --- /dev/null +++ b/slider_test.go @@ -0,0 +1,264 @@ +package survey + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/Iilun/survey/v2/core" + "github.com/Iilun/survey/v2/terminal" +) + +func init() { + // disable color output for all prompts to simplify testing + core.DisableColor = true +} + +func TestSliderRender(t *testing.T) { + + prompt := Slider{ + Message: "Pick your number:", + Max: 50, + Default: 40, + } + + helpfulPrompt := prompt + helpfulPrompt.Help = "This is helpful" + + tests := []struct { + title string + prompt Slider + promptOption AskOpt + data SliderTemplateData + expected string + }{ + { + "Test Slider question output", + prompt, + nil, + SliderTemplateData{SelectedValue: 3}, + strings.Join( + []string{ + fmt.Sprintf("%s Pick your number: [Use side arrows to alter value by 1, vertical arrows to alter value by 10]", defaultIcons().Question.Text), + fmt.Sprintf(" %s%s%s", defaultIcons().SliderFiller.Text, defaultIcons().SliderCursor.Text, strings.Repeat(defaultIcons().SliderFiller.Text, 23)), + }, + "\n", + ), + }, + { + "Test Slider answer output", + prompt, + nil, + SliderTemplateData{SelectedValue: 15, ShowAnswer: true}, + fmt.Sprintf("%s Pick your number: 15\n", defaultIcons().Question.Text), + }, + { + "Test Slider question output with help hidden", + helpfulPrompt, + nil, + SliderTemplateData{SelectedValue: 3}, + strings.Join( + []string{ + fmt.Sprintf("%s Pick your number: [Use side arrows to alter value by 1, vertical arrows to alter value by 10, %s for more help]", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput)), + fmt.Sprintf(" %s%s%s", defaultIcons().SliderFiller.Text, defaultIcons().SliderCursor.Text, strings.Repeat(defaultIcons().SliderFiller.Text, 23)), + }, + "\n", + ), + }, + { + "Test Slider question output with help shown", + helpfulPrompt, + nil, + SliderTemplateData{SelectedValue: 3, ShowHelp: true}, + strings.Join( + []string{ + fmt.Sprintf("%s This is helpful", defaultIcons().Help.Text), + fmt.Sprintf("%s Pick your number: [Use side arrows to alter value by 1, vertical arrows to alter value by 10]", defaultIcons().Question.Text), + fmt.Sprintf(" %s%s%s", defaultIcons().SliderFiller.Text, defaultIcons().SliderCursor.Text, strings.Repeat(defaultIcons().SliderFiller.Text, 23)), + }, + "\n", + ), + }, + } + + for _, test := range tests { + t.Run(test.title, func(t *testing.T) { + r, w, err := os.Pipe() + assert.NoError(t, err) + + test.prompt.WithStdio(terminal.Stdio{Out: w}) + test.data.Slider = test.prompt + + options := defaultAskOptions() + if test.promptOption != nil { + err = test.promptOption(options) + assert.NoError(t, err) + } + // set the icon set + test.data.Config = &options.PromptConfig + + // Compute the state + test.prompt.MaxSize = 25 + test.prompt.selectedValue = test.data.SelectedValue + test.data.ChangeInterval = 10 + test.data.SliderContent = test.prompt.computeSliderContent(test.data.Config) + + err = test.prompt.Render( + SliderQuestionTemplate, + test.data, + ) + assert.NoError(t, err) + + assert.NoError(t, w.Close()) + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + assert.NoError(t, err) + + assert.Contains(t, buf.String(), test.expected) + }) + } +} + +func TestSliderPrompt(t *testing.T) { + + //Overflow, min max custom, up arrows, help + tests := []PromptTest{ + { + "basic interaction: 1", + &Slider{ + Message: "Choose a number:", + Max: 25, + }, + nil, + func(c expectConsole) { + c.ExpectString("*------------------------- 0") + // Increase the value by one + c.Send(string(terminal.KeyArrowRight)) + c.ExpectString("-*------------------------ 1") + // Validate + c.SendLine("") + c.ExpectEOF() + }, + 1, + }, + { + "basic interaction: default", + &Slider{ + Message: "Choose a number:", + Default: 20, + Max: 25, + }, + nil, + func(c expectConsole) { + c.ExpectString("--------------------*----- 20") + // Decrease the value by one + c.Send(string(terminal.KeyArrowLeft)) + c.ExpectString("-------------------*------ 19") + // Validate + c.SendLine("") + c.ExpectEOF() + }, + 19, + }, + { + "basic interaction: custom min and max", + &Slider{ + Message: "Choose a number:", + Min: 20, + Max: 70, + }, + nil, + func(c expectConsole) { + c.ExpectString("*------------------------- 20") + // Decrease the value by one - does nothing + c.Send(string(terminal.KeyArrowLeft)) + c.ExpectString("*------------------------- 20") + // Validate + c.SendLine("") + c.ExpectEOF() + }, + 20, + }, + { + "basic interaction: custom min and max", + &Slider{ + Message: "Choose a number:", + Min: -70, + Max: -20, + }, + nil, + func(c expectConsole) { + c.ExpectString("*------------------------- -70") + // Decrease the value by 10 - does nothing + c.Send(string(terminal.KeyArrowDown)) + c.ExpectString("*------------------------- -70") + // Validate + c.SendLine("") + c.ExpectEOF() + }, + -70, + }, + { + "basic interaction: overflow max", + &Slider{ + Message: "Choose a number:", + Max: 10, + }, + nil, + func(c expectConsole) { + c.ExpectString("*---------- 0") + // Increase the value by 10 + c.Send(string(terminal.KeyArrowUp)) + c.ExpectString("----------* 10") + // Increase the value by 10 - does nothing + c.Send(string(terminal.KeyArrowUp)) + c.ExpectString("----------* 10") + // Increase the value by 1 - does nothing + c.Send(string(terminal.KeyArrowRight)) + c.ExpectString("----------* 10") + // Validate + c.SendLine("") + c.ExpectEOF() + }, + 10, + }, + } + + for _, test := range tests { + testName := strings.TrimPrefix(test.name, "SKIP: ") + t.Run(testName, func(t *testing.T) { + if testName != test.name { + t.Skipf("warning: flakey test %q", testName) + } + RunPromptTest(t, test) + }) + } +} + +func TestSliderError(t *testing.T) { + // Error should be sent on: + // - max < min + // - max == min + // - default outside min max interval + + invalidSliders := []Slider{ + {Max: 10, Min: 50}, + {Max: 10, Min: 10}, + {Max: 15, Min: 10, Default: -40}, + } + + for _, slider := range invalidSliders { + var output int + err := Ask([]*Question{{Prompt: &slider}}, &output) + // if we didn't get an error + if err == nil { + // the test failed + t.Errorf("Did not encounter error when asking whith invalid slider: %s", fmt.Sprint(slider)) + } + } +} diff --git a/survey.go b/survey.go index b8567e2d..95b603d8 100644 --- a/survey.go +++ b/survey.go @@ -53,6 +53,14 @@ func defaultAskOptions() *AskOptions { Text: ">", Format: "cyan+b", }, + SliderFiller: Icon{ + Text: "-", + Format: "default+hb", + }, + SliderCursor: Icon{ + Text: "*", + Format: "cyan+b", + }, }, Filter: func(filter string, value string, index int) (include bool) { filter = strings.ToLower(filter) @@ -95,6 +103,8 @@ type IconSet struct { MarkedOption Icon UnmarkedOption Icon SelectFocus Icon + SliderFiller Icon + SliderCursor Icon } // Validator is a function passed to a Question after a user has provided a response. @@ -176,7 +186,7 @@ func WithFilter(filter func(filter string, value string, index int) (include boo } } -// WithDisableFilter specifies disables the filter behavior. +// WithDisableFilter disables the filter behavior. func WithDisableFilter() AskOpt { return func(options *AskOptions) error { // save the boolean internally