├── filter.go ├── img └── multi-select-all-none.gif ├── terminal ├── terminal.go ├── display.go ├── README.md ├── error.go ├── display_posix.go ├── runereader_ppc64le.go ├── buffered_reader.go ├── stdio.go ├── output.go ├── runereader_bsd.go ├── runereader_linux.go ├── sequences.go ├── display_windows.go ├── syscall_windows.go ├── LICENSE.txt ├── runereader_test.go ├── runereader_posix.go ├── runereader_windows.go ├── cursor_windows.go ├── cursor.go ├── output_windows.go └── runereader.go ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ └── bug.md ├── matchers.json └── workflows │ └── test.yml ├── survey_windows_test.go ├── tests ├── README.md ├── longSelect.go ├── password.go ├── selectThenInput.go ├── doubleSelect.go ├── confirm.go ├── input.go ├── util │ └── test.go ├── ask.go ├── help.go ├── select.go ├── editor.go └── multiselect.go ├── go.mod ├── examples ├── cursor.go ├── longlist.go ├── map.go ├── inputfilesuggestion.go ├── simple.go ├── password.go ├── validation.go ├── select_description.go ├── longmulti.go ├── countrylist.go └── longmultikeepfilter.go ├── renderer_test.go ├── LICENSE ├── survey_posix_test.go ├── transform_test.go ├── renderer_posix_test.go ├── password_test.go ├── transform.go ├── password.go ├── multiline.go ├── CONTRIBUTING.md ├── core ├── template.go └── write.go ├── confirm.go ├── confirm_test.go ├── validate.go ├── multiline_test.go ├── go.sum ├── renderer.go ├── validate_test.go ├── editor.go ├── input.go ├── editor_test.go ├── select.go ├── select_test.go └── multiselect.go /filter.go: -------------------------------------------------------------------------------- 1 | package survey 2 | -------------------------------------------------------------------------------- /img/multi-select-all-none.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azumi67/survey/HEAD/img/multi-select-all-none.gif -------------------------------------------------------------------------------- /terminal/terminal.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | type Short int16 4 | 5 | type Coord struct { 6 | X Short 7 | Y Short 8 | } 9 | -------------------------------------------------------------------------------- /terminal/display.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | type EraseLineMode int 4 | 5 | const ( 6 | ERASE_LINE_END EraseLineMode = iota 7 | ERASE_LINE_START 8 | ERASE_LINE_ALL 9 | ) 10 | -------------------------------------------------------------------------------- /terminal/README.md: -------------------------------------------------------------------------------- 1 | # survey/terminal 2 | 3 | This package started as a copy of [kokuban/go-ansi](http://github.com/k0kubun/go-ansi) but has since been modified to fit survey's specific needs. 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ask for help 3 | about: Suggest an idea for this project or ask for help 4 | title: '' 5 | labels: 'Question' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /terminal/error.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | //lint:ignore ST1012 keeping old name for backwards compatibility 9 | InterruptErr = errors.New("interrupt") 10 | ) 11 | -------------------------------------------------------------------------------- /terminal/display_posix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package terminal 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | func EraseLine(out FileWriter, mode EraseLineMode) error { 11 | _, err := fmt.Fprintf(out, "\x1b[%dK", mode) 12 | return err 13 | } 14 | -------------------------------------------------------------------------------- /survey_windows_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/AlecAivazis/survey/v2/terminal" 7 | ) 8 | 9 | func RunTest(t *testing.T, procedure func(expectConsole), test func(terminal.Stdio) error) { 10 | t.Skip("warning: Windows does not support psuedoterminals") 11 | } 12 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # survey/tests 2 | 3 | Because of the nature of this library, I was having a hard time finding a reliable 4 | way to run unit tests, therefore I decided to try to create a suite 5 | of integration tests which must be run successfully before a PR can be merged. 6 | I will try to add to this suite as new edge cases are known. 7 | -------------------------------------------------------------------------------- /terminal/runereader_ppc64le.go: -------------------------------------------------------------------------------- 1 | //go:build ppc64le && linux 2 | // +build ppc64le,linux 3 | 4 | package terminal 5 | 6 | // Used syscall numbers from https://github.com/golang/go/blob/master/src/syscall/ztypes_linux_ppc64le.go 7 | const ioctlReadTermios = 0x402c7413 // syscall.TCGETS 8 | const ioctlWriteTermios = 0x802c7414 // syscall.TCSETS 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'Bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What operating system and terminal are you using?** 11 | 12 | **An example that showcases the bug.** 13 | 14 | **What did you expect to see?** 15 | 16 | **What did you see instead?** 17 | -------------------------------------------------------------------------------- /tests/longSelect.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import "github.com/AlecAivazis/survey/v2" 6 | 7 | func main() { 8 | color := "" 9 | prompt := &survey.Select{ 10 | Message: "Choose a color:", 11 | Options: []string{ 12 | "a", 13 | "b", 14 | "c", 15 | "d", 16 | "e", 17 | "f", 18 | "g", 19 | "h", 20 | "i", 21 | "j", 22 | }, 23 | } 24 | survey.AskOne(prompt, &color) 25 | } 26 | -------------------------------------------------------------------------------- /terminal/buffered_reader.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | type BufferedReader struct { 9 | In io.Reader 10 | Buffer *bytes.Buffer 11 | } 12 | 13 | func (br *BufferedReader) Read(p []byte) (int, error) { 14 | n, err := br.Buffer.Read(p) 15 | if err != nil && err != io.EOF { 16 | return n, err 17 | } else if err == nil { 18 | return n, nil 19 | } 20 | 21 | return br.In.Read(p[n:]) 22 | } 23 | -------------------------------------------------------------------------------- /.github/matchers.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "go", 5 | "pattern": [ 6 | { 7 | "regexp": "^\\s*(.+\\.go):(\\d+):(?:(\\d+):)? (?:(warning): )?(.*)", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "severity": 4, 12 | "message": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /terminal/stdio.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | // Stdio is the standard input/output the terminal reads/writes with. 8 | type Stdio struct { 9 | In FileReader 10 | Out FileWriter 11 | Err io.Writer 12 | } 13 | 14 | // FileWriter provides a minimal interface for Stdin. 15 | type FileWriter interface { 16 | io.Writer 17 | Fd() uintptr 18 | } 19 | 20 | // FileReader provides a minimal interface for Stdout. 21 | type FileReader interface { 22 | io.Reader 23 | Fd() uintptr 24 | } 25 | -------------------------------------------------------------------------------- /terminal/output.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package terminal 5 | 6 | import ( 7 | "io" 8 | ) 9 | 10 | // NewAnsiStdout returns special stdout, which converts escape sequences to Windows API calls 11 | // on Windows environment. 12 | func NewAnsiStdout(out FileWriter) io.Writer { 13 | return out 14 | } 15 | 16 | // NewAnsiStderr returns special stderr, which converts escape sequences to Windows API calls 17 | // on Windows environment. 18 | func NewAnsiStderr(out FileWriter) io.Writer { 19 | return out 20 | } 21 | -------------------------------------------------------------------------------- /terminal/runereader_bsd.go: -------------------------------------------------------------------------------- 1 | // copied from: https://github.com/golang/crypto/blob/master/ssh/terminal/util_bsd.go 2 | // Copyright 2013 The Go Authors. All rights reserved. 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file. 5 | 6 | //go:build darwin || dragonfly || freebsd || netbsd || openbsd 7 | // +build darwin dragonfly freebsd netbsd openbsd 8 | 9 | package terminal 10 | 11 | import "syscall" 12 | 13 | const ioctlReadTermios = syscall.TIOCGETA 14 | const ioctlWriteTermios = syscall.TIOCSETA 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AlecAivazis/survey/v2 2 | 3 | require ( 4 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 5 | github.com/creack/pty v1.1.17 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec 8 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 9 | github.com/mattn/go-colorable v0.1.2 // indirect 10 | github.com/mattn/go-isatty v0.0.8 11 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b 12 | github.com/stretchr/testify v1.6.1 13 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 14 | golang.org/x/text v0.4.0 15 | ) 16 | 17 | go 1.13 18 | -------------------------------------------------------------------------------- /tests/password.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/AlecAivazis/survey/v2" 7 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 8 | ) 9 | 10 | var value = "" 11 | 12 | var table = []TestUtil.TestTableEntry{ 13 | { 14 | "standard", &survey.Password{Message: "Please type your password:"}, &value, nil, 15 | }, 16 | { 17 | "please make sure paste works", &survey.Password{Message: "Please paste your password:"}, &value, nil, 18 | }, 19 | { 20 | "no help, send '?'", &survey.Password{Message: "Please type your password:"}, &value, nil, 21 | }, 22 | } 23 | 24 | func main() { 25 | TestUtil.RunTable(table) 26 | } 27 | -------------------------------------------------------------------------------- /terminal/runereader_linux.go: -------------------------------------------------------------------------------- 1 | // copied from https://github.com/golang/crypto/blob/master/ssh/terminal/util_linux.go 2 | // Copyright 2013 The Go Authors. All rights reserved. 3 | // Use of this source code is governed by a BSD-style 4 | // license that can be found in the LICENSE file. 5 | //go:build linux && !ppc64le 6 | // +build linux,!ppc64le 7 | 8 | package terminal 9 | 10 | // These constants are declared here, rather than importing 11 | // them from the syscall package as some syscall packages, even 12 | // on linux, for example gccgo, do not declare them. 13 | const ioctlReadTermios = 0x5401 // syscall.TCGETS 14 | const ioctlWriteTermios = 0x5402 // syscall.TCSETS 15 | -------------------------------------------------------------------------------- /examples/cursor.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var simpleQs = []*survey.Question{ 13 | { 14 | Name: "name", 15 | Prompt: &survey.Input{ 16 | Message: "What is your name?", 17 | }, 18 | Validate: survey.Required, 19 | }, 20 | } 21 | 22 | func main() { 23 | ansmap := make(map[string]interface{}) 24 | 25 | // ask the question 26 | err := survey.Ask(simpleQs, &ansmap, survey.WithShowCursor(true)) 27 | 28 | if err != nil { 29 | fmt.Println(err.Error()) 30 | return 31 | } 32 | // print the answers 33 | fmt.Printf("Your name is %s.\n", ansmap["name"]) 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | platform: [ubuntu-latest, macos-latest, windows-latest] 11 | go: ["1.19", "1.18", "1.17", "1.16"] 12 | 13 | runs-on: ${{ matrix.platform }} 14 | 15 | steps: 16 | - name: Check out code 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: "${{ matrix.go }}" 23 | cache: true 24 | 25 | - name: Add problem matcher 26 | run: echo "::add-matcher::$PWD/.github/matchers.json" 27 | 28 | - name: Run tests 29 | run: go test -timeout 30s -v $(go list -v ./... | grep -vE '(tests|examples)$') 30 | -------------------------------------------------------------------------------- /renderer_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/AlecAivazis/survey/v2/core" 8 | ) 9 | 10 | func TestValidationError(t *testing.T) { 11 | 12 | err := fmt.Errorf("Football is not a valid month") 13 | 14 | actual, _, err := core.RunTemplate( 15 | ErrorTemplate, 16 | &ErrorTemplateData{ 17 | Error: err, 18 | Icon: defaultIcons().Error, 19 | }, 20 | ) 21 | if err != nil { 22 | t.Errorf("Failed to run template to format error: %s", err) 23 | } 24 | 25 | expected := fmt.Sprintf("%s Sorry, your reply was invalid: Football is not a valid month\n", defaultIcons().Error.Text) 26 | 27 | if actual != expected { 28 | t.Errorf("Formatted error was not formatted correctly. Found:\n%s\nExpected:\n%s", actual, expected) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /terminal/sequences.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | ) 7 | 8 | const ( 9 | KeyArrowLeft = '\x02' 10 | KeyArrowRight = '\x06' 11 | KeyArrowUp = '\x10' 12 | KeyArrowDown = '\x0e' 13 | KeySpace = ' ' 14 | KeyEnter = '\r' 15 | KeyBackspace = '\b' 16 | KeyDelete = '\x7f' 17 | KeyInterrupt = '\x03' 18 | KeyEndTransmission = '\x04' 19 | KeyEscape = '\x1b' 20 | KeyDeleteWord = '\x17' // Ctrl+W 21 | KeyDeleteLine = '\x18' // Ctrl+X 22 | SpecialKeyHome = '\x01' 23 | SpecialKeyEnd = '\x11' 24 | SpecialKeyDelete = '\x12' 25 | IgnoreKey = '\000' 26 | KeyTab = '\t' 27 | ) 28 | 29 | func soundBell(out io.Writer) error { 30 | _, err := fmt.Fprint(out, "\a") 31 | return err 32 | } 33 | -------------------------------------------------------------------------------- /examples/longlist.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var simpleQs = []*survey.Question{ 13 | { 14 | Name: "letter", 15 | Prompt: &survey.Select{ 16 | Message: "Choose a letter:", 17 | Options: []string{ 18 | "a", 19 | "b", 20 | "c", 21 | "d", 22 | "e", 23 | "f", 24 | "g", 25 | "h", 26 | "i", 27 | "j", 28 | }, 29 | }, 30 | Validate: survey.Required, 31 | }, 32 | } 33 | 34 | func main() { 35 | answers := struct { 36 | Letter string 37 | }{} 38 | 39 | // ask the question 40 | err := survey.Ask(simpleQs, &answers) 41 | 42 | if err != nil { 43 | fmt.Println(err.Error()) 44 | return 45 | } 46 | // print the answers 47 | fmt.Printf("you chose %s.\n", answers.Letter) 48 | } 49 | -------------------------------------------------------------------------------- /examples/map.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var simpleQs = []*survey.Question{ 13 | { 14 | Name: "name", 15 | Prompt: &survey.Input{ 16 | Message: "What is your name?", 17 | }, 18 | Validate: survey.Required, 19 | }, 20 | { 21 | Name: "color", 22 | Prompt: &survey.Select{ 23 | Message: "Choose a color:", 24 | Options: []string{"red", "blue", "green"}, 25 | }, 26 | Validate: survey.Required, 27 | }, 28 | } 29 | 30 | func main() { 31 | ansmap := make(map[string]interface{}) 32 | 33 | // ask the question 34 | err := survey.Ask(simpleQs, &ansmap) 35 | 36 | if err != nil { 37 | fmt.Println(err.Error()) 38 | return 39 | } 40 | // print the answers 41 | fmt.Printf("%s chose %s.\n", ansmap["name"], ansmap["color"]) 42 | } 43 | -------------------------------------------------------------------------------- /terminal/display_windows.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "syscall" 5 | "unsafe" 6 | ) 7 | 8 | func EraseLine(out FileWriter, mode EraseLineMode) error { 9 | handle := syscall.Handle(out.Fd()) 10 | 11 | var csbi consoleScreenBufferInfo 12 | if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { 13 | return err 14 | } 15 | 16 | var w uint32 17 | var x Short 18 | cursor := csbi.cursorPosition 19 | switch mode { 20 | case ERASE_LINE_END: 21 | x = csbi.size.X 22 | case ERASE_LINE_START: 23 | x = 0 24 | case ERASE_LINE_ALL: 25 | cursor.X = 0 26 | x = csbi.size.X 27 | } 28 | 29 | _, _, err := procFillConsoleOutputCharacter.Call(uintptr(handle), uintptr(' '), uintptr(x), uintptr(*(*int32)(unsafe.Pointer(&cursor))), uintptr(unsafe.Pointer(&w))) 30 | return normalizeError(err) 31 | } 32 | -------------------------------------------------------------------------------- /tests/selectThenInput.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var simpleQs = []*survey.Question{ 13 | { 14 | Name: "color", 15 | Prompt: &survey.Select{ 16 | Message: "Choose a color:", 17 | Options: []string{"red", "blue", "green"}, 18 | }, 19 | Validate: survey.Required, 20 | }, 21 | { 22 | Name: "name", 23 | Prompt: &survey.Input{ 24 | Message: "What is your name?", 25 | }, 26 | Validate: survey.Required, 27 | }, 28 | } 29 | 30 | func main() { 31 | answers := struct { 32 | Color string 33 | Name string 34 | }{} 35 | // ask the question 36 | err := survey.Ask(simpleQs, &answers) 37 | 38 | if err != nil { 39 | fmt.Println(err.Error()) 40 | return 41 | } 42 | // print the answers 43 | fmt.Printf("%s chose %s.\n", answers.Name, answers.Color) 44 | } 45 | -------------------------------------------------------------------------------- /tests/doubleSelect.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | var simpleQs = []*survey.Question{ 12 | { 13 | Name: "color", 14 | Prompt: &survey.Select{ 15 | Message: "select1:", 16 | Options: []string{"red", "blue", "green"}, 17 | }, 18 | Validate: survey.Required, 19 | }, 20 | { 21 | Name: "color2", 22 | Prompt: &survey.Select{ 23 | Message: "select2:", 24 | Options: []string{"red", "blue", "green"}, 25 | }, 26 | Validate: survey.Required, 27 | }, 28 | } 29 | 30 | func main() { 31 | answers := struct { 32 | Color string 33 | Color2 string 34 | }{} 35 | // ask the question 36 | err := survey.Ask(simpleQs, &answers) 37 | 38 | if err != nil { 39 | fmt.Println(err.Error()) 40 | return 41 | } 42 | // print the answers 43 | fmt.Printf("%s and %s.\n", answers.Color, answers.Color2) 44 | } 45 | -------------------------------------------------------------------------------- /examples/inputfilesuggestion.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "path/filepath" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | ) 11 | 12 | func suggestFiles(toComplete string) []string { 13 | files, _ := filepath.Glob(toComplete + "*") 14 | return files 15 | } 16 | 17 | // the questions to ask 18 | var q = []*survey.Question{ 19 | { 20 | Name: "file", 21 | Prompt: &survey.Input{ 22 | Message: "Which file should be read?", 23 | Suggest: suggestFiles, 24 | Help: "Any file; do not need to exist yet", 25 | }, 26 | Validate: survey.Required, 27 | }, 28 | } 29 | 30 | func main() { 31 | answers := struct { 32 | File string 33 | }{} 34 | 35 | // ask the question 36 | err := survey.Ask(q, &answers) 37 | 38 | if err != nil { 39 | fmt.Println(err.Error()) 40 | return 41 | } 42 | // print the answers 43 | fmt.Printf("File chosen %s.\n", answers.File) 44 | } 45 | -------------------------------------------------------------------------------- /tests/confirm.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/AlecAivazis/survey/v2" 7 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 8 | ) 9 | 10 | var answer = false 11 | 12 | var goodTable = []TestUtil.TestTableEntry{ 13 | { 14 | "Enter 'yes'", &survey.Confirm{ 15 | Message: "yes:", 16 | }, &answer, nil, 17 | }, 18 | { 19 | "Enter 'no'", &survey.Confirm{ 20 | Message: "yes:", 21 | }, &answer, nil, 22 | }, 23 | { 24 | "default", &survey.Confirm{ 25 | Message: "yes:", 26 | Default: true, 27 | }, &answer, nil, 28 | }, 29 | { 30 | "not recognized (enter random letter)", &survey.Confirm{ 31 | Message: "yes:", 32 | Default: true, 33 | }, &answer, nil, 34 | }, 35 | { 36 | "no help - type '?'", &survey.Confirm{ 37 | Message: "yes:", 38 | Default: true, 39 | }, &answer, nil, 40 | }, 41 | } 42 | 43 | func main() { 44 | TestUtil.RunTable(goodTable) 45 | } 46 | -------------------------------------------------------------------------------- /examples/simple.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var simpleQs = []*survey.Question{ 13 | { 14 | Name: "name", 15 | Prompt: &survey.Input{ 16 | Message: "What is your name?", 17 | }, 18 | Validate: survey.Required, 19 | Transform: survey.Title, 20 | }, 21 | { 22 | Name: "color", 23 | Prompt: &survey.Select{ 24 | Message: "Choose a color:", 25 | Options: []string{"red", "blue", "green"}, 26 | }, 27 | Validate: survey.Required, 28 | }, 29 | } 30 | 31 | func main() { 32 | answers := struct { 33 | Name string 34 | Color string 35 | }{} 36 | 37 | // ask the question 38 | err := survey.Ask(simpleQs, &answers) 39 | 40 | if err != nil { 41 | fmt.Println(err.Error()) 42 | return 43 | } 44 | // print the answers 45 | fmt.Printf("%s chose %s.\n", answers.Name, answers.Color) 46 | } 47 | -------------------------------------------------------------------------------- /examples/password.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var defaultPasswordCharacterPrompt = &survey.Password{ 13 | Message: "What is your password? (Default hide character)", 14 | } 15 | var customPasswordCharacterPrompt = &survey.Password{ 16 | Message: "What is your password? (Custom hide character)", 17 | } 18 | 19 | func main() { 20 | 21 | var defaultPass string 22 | var customPass string 23 | 24 | // ask the question 25 | err := survey.AskOne(defaultPasswordCharacterPrompt, &defaultPass) 26 | if err != nil { 27 | fmt.Println(err.Error()) 28 | return 29 | } 30 | fmt.Println() 31 | err = survey.AskOne(customPasswordCharacterPrompt, &customPass, survey.WithHideCharacter('-')) 32 | if err != nil { 33 | fmt.Println(err.Error()) 34 | return 35 | } 36 | // print the answers 37 | fmt.Printf("Password 1: %s.\n Password 2: %s\n", defaultPass, customPass) 38 | } 39 | -------------------------------------------------------------------------------- /examples/validation.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var validationQs = []*survey.Question{ 13 | { 14 | Name: "name", 15 | Prompt: &survey.Input{Message: "What is your name?"}, 16 | Validate: survey.Required, 17 | }, 18 | { 19 | Name: "valid", 20 | Prompt: &survey.Input{Message: "Enter 'foo':", Default: "not foo"}, 21 | Validate: func(val interface{}) error { 22 | // if the input matches the expectation 23 | if str := val.(string); str != "foo" { 24 | return fmt.Errorf("You entered %s, not 'foo'.", str) 25 | } 26 | // nothing was wrong 27 | return nil 28 | }, 29 | }, 30 | } 31 | 32 | func main() { 33 | // the place to hold the answers 34 | answers := struct { 35 | Name string 36 | Valid string 37 | }{} 38 | err := survey.Ask(validationQs, &answers) 39 | 40 | if err != nil { 41 | fmt.Println("\n", err.Error()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/input.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/AlecAivazis/survey/v2" 7 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 8 | ) 9 | 10 | var val = "" 11 | 12 | var table = []TestUtil.TestTableEntry{ 13 | { 14 | "no default", &survey.Input{Message: "Hello world"}, &val, nil, 15 | }, 16 | { 17 | "default", &survey.Input{Message: "Hello world", Default: "default"}, &val, nil, 18 | }, 19 | { 20 | "no help, send '?'", &survey.Input{Message: "Hello world"}, &val, nil, 21 | }, 22 | { 23 | "Home, End Button test in random location", &survey.Input{Message: "Hello world"}, &val, nil, 24 | }, { 25 | "Delete and forward delete test at random location (test if screen overflows)", &survey.Input{Message: "Hello world"}, &val, nil, 26 | }, { 27 | "Moving around lines with left & right arrow keys", &survey.Input{Message: "Hello world"}, &val, nil, 28 | }, { 29 | "Runes with width > 1. Enter 一 you get to the next line", &survey.Input{Message: "Hello world"}, &val, nil, 30 | }, 31 | } 32 | 33 | func main() { 34 | TestUtil.RunTable(table) 35 | } 36 | -------------------------------------------------------------------------------- /examples/select_description.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | type Meal struct { 12 | Title string 13 | Comment string 14 | } 15 | 16 | func main() { 17 | var meals = []Meal{ 18 | {Title: "Bread", Comment: "Contains gluten"}, 19 | {Title: "Eggs", Comment: "Free-range"}, 20 | {Title: "Apple", Comment: ""}, 21 | {Title: "Burger", Comment: "Veggie patties available"}, 22 | } 23 | 24 | titles := make([]string, len(meals)) 25 | for i, m := range meals { 26 | titles[i] = m.Title 27 | } 28 | var qs = &survey.Select{ 29 | Message: "Choose a meal:", 30 | Options: titles, 31 | Description: func(value string, index int) string { 32 | return meals[index].Comment 33 | }, 34 | } 35 | 36 | answerIndex := 0 37 | 38 | // ask the question 39 | err := survey.AskOne(qs, &answerIndex) 40 | 41 | if err != nil { 42 | fmt.Println(err.Error()) 43 | return 44 | } 45 | 46 | meal := meals[answerIndex] 47 | // print the answers 48 | fmt.Printf("you picked %s, nice choice.\n", meal.Title) 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alec Aivazis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /terminal/syscall_windows.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "syscall" 5 | ) 6 | 7 | var ( 8 | kernel32 = syscall.NewLazyDLL("kernel32.dll") 9 | procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") 10 | procSetConsoleTextAttribute = kernel32.NewProc("SetConsoleTextAttribute") 11 | procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition") 12 | procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW") 13 | procGetConsoleCursorInfo = kernel32.NewProc("GetConsoleCursorInfo") 14 | procSetConsoleCursorInfo = kernel32.NewProc("SetConsoleCursorInfo") 15 | ) 16 | 17 | type wchar uint16 18 | type dword uint32 19 | type word uint16 20 | 21 | type smallRect struct { 22 | left Short 23 | top Short 24 | right Short 25 | bottom Short 26 | } 27 | 28 | type consoleScreenBufferInfo struct { 29 | size Coord 30 | cursorPosition Coord 31 | attributes word 32 | window smallRect 33 | maximumWindowSize Coord 34 | } 35 | 36 | type consoleCursorInfo struct { 37 | size dword 38 | visible int32 39 | } 40 | -------------------------------------------------------------------------------- /terminal/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Takashi Kokubun 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /survey_posix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package survey 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/AlecAivazis/survey/v2/terminal" 10 | expect "github.com/Netflix/go-expect" 11 | pseudotty "github.com/creack/pty" 12 | "github.com/hinshun/vt10x" 13 | ) 14 | 15 | func RunTest(t *testing.T, procedure func(expectConsole), test func(terminal.Stdio) error) { 16 | t.Helper() 17 | t.Parallel() 18 | 19 | pty, tty, err := pseudotty.Open() 20 | if err != nil { 21 | t.Fatalf("failed to open pseudotty: %v", err) 22 | } 23 | 24 | term := vt10x.New(vt10x.WithWriter(tty)) 25 | c, err := expect.NewConsole(expect.WithStdin(pty), expect.WithStdout(term), expect.WithCloser(pty, tty)) 26 | if err != nil { 27 | t.Fatalf("failed to create console: %v", err) 28 | } 29 | defer c.Close() 30 | 31 | donec := make(chan struct{}) 32 | go func() { 33 | defer close(donec) 34 | procedure(&consoleWithErrorHandling{console: c, t: t}) 35 | }() 36 | 37 | stdio := terminal.Stdio{In: c.Tty(), Out: c.Tty(), Err: c.Tty()} 38 | if err := test(stdio); err != nil { 39 | t.Error(err) 40 | } 41 | 42 | if err := c.Tty().Close(); err != nil { 43 | t.Errorf("error closing Tty: %v", err) 44 | } 45 | <-donec 46 | } 47 | -------------------------------------------------------------------------------- /tests/util/test.go: -------------------------------------------------------------------------------- 1 | package TestUtil 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/AlecAivazis/survey/v2" 8 | ) 9 | 10 | type TestTableEntry struct { 11 | Name string 12 | Prompt survey.Prompt 13 | Value interface{} 14 | Validate func(interface{}) error 15 | } 16 | 17 | func formatAnswer(ans interface{}) { 18 | // show the answer to the user 19 | fmt.Printf("Answered %v.\n", reflect.ValueOf(ans).Elem()) 20 | fmt.Println("---------------------") 21 | } 22 | 23 | func RunTable(table []TestTableEntry) { 24 | // go over every entry in the table 25 | for _, entry := range table { 26 | // tell the user what we are going to ask them 27 | fmt.Println(entry.Name) 28 | // perform the ask 29 | err := survey.AskOne(entry.Prompt, entry.Value) 30 | if err != nil { 31 | fmt.Printf("AskOne on %v's prompt failed: %v.", entry.Name, err.Error()) 32 | break 33 | } 34 | // show the answer to the user 35 | formatAnswer(entry.Value) 36 | } 37 | } 38 | 39 | func RunErrorTable(table []TestTableEntry) { 40 | // go over every entry in the table 41 | for _, entry := range table { 42 | // tell the user what we are going to ask them 43 | fmt.Println(entry.Name) 44 | // perform the ask 45 | err := survey.AskOne(entry.Prompt, entry.Value, survey.WithValidator(entry.Validate)) 46 | if err == nil { 47 | fmt.Printf("AskOne on %v's prompt didn't fail.", entry.Name) 48 | break 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/ask.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var simpleQs = []*survey.Question{ 13 | { 14 | Name: "name", 15 | Prompt: &survey.Input{ 16 | Message: "What is your name?", 17 | Default: "Johnny Appleseed", 18 | }, 19 | }, 20 | { 21 | Name: "color", 22 | Prompt: &survey.Select{ 23 | Message: "Choose a color:", 24 | Options: []string{"red", "blue", "green", "yellow"}, 25 | Default: "yellow", 26 | }, 27 | Validate: survey.Required, 28 | }, 29 | } 30 | 31 | var singlePrompt = &survey.Input{ 32 | Message: "What is your name?", 33 | Default: "Johnny Appleseed", 34 | } 35 | 36 | func main() { 37 | 38 | fmt.Println("Asking many.") 39 | // a place to store the answers 40 | ans := struct { 41 | Name string 42 | Color string 43 | }{} 44 | err := survey.Ask(simpleQs, &ans) 45 | if err != nil { 46 | fmt.Println(err.Error()) 47 | return 48 | } 49 | 50 | fmt.Println("Asking one.") 51 | answer := "" 52 | 53 | err = survey.AskOne(singlePrompt, &answer) 54 | if err != nil { 55 | fmt.Println(err.Error()) 56 | return 57 | } 58 | fmt.Printf("Answered with %v.\n", answer) 59 | 60 | fmt.Println("Asking one with validation.") 61 | vAns := "" 62 | err = survey.AskOne(&survey.Input{Message: "What is your name?"}, &vAns, survey.WithValidator(survey.Required)) 63 | if err != nil { 64 | fmt.Println(err.Error()) 65 | return 66 | } 67 | fmt.Printf("Answered with %v.\n", vAns) 68 | } 69 | -------------------------------------------------------------------------------- /tests/help.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/AlecAivazis/survey/v2" 7 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 8 | ) 9 | 10 | var ( 11 | confirmAns = false 12 | inputAns = "" 13 | multiselectAns = []string{} 14 | selectAns = "" 15 | passwordAns = "" 16 | ) 17 | 18 | var goodTable = []TestUtil.TestTableEntry{ 19 | { 20 | "confirm", &survey.Confirm{ 21 | Message: "Is it raining?", 22 | Help: "Go outside, if your head becomes wet the answer is probably 'yes'", 23 | }, &confirmAns, nil, 24 | }, 25 | { 26 | "input", &survey.Input{ 27 | Message: "What is your phone number:", 28 | Help: "Phone number should include the area code, parentheses optional", 29 | }, &inputAns, nil, 30 | }, 31 | { 32 | "select", &survey.MultiSelect{ 33 | Message: "What days are you available:", 34 | Help: "We are closed weekends and avaibility is limited on Wednesday", 35 | Options: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday"}, 36 | Default: []string{"Monday", "Tuesday", "Thursday", "Friday"}, 37 | }, &multiselectAns, nil, 38 | }, 39 | { 40 | "select", &survey.Select{ 41 | Message: "Choose a color:", 42 | Help: "Blue is the best color, but it is your choice", 43 | Options: []string{"red", "blue", "green"}, 44 | Default: "blue", 45 | }, &selectAns, nil, 46 | }, 47 | { 48 | "password", &survey.Password{ 49 | Message: "Enter a secret:", 50 | Help: "Don't really enter a secret, this is just for testing", 51 | }, &passwordAns, nil, 52 | }, 53 | } 54 | 55 | func main() { 56 | TestUtil.RunTable(goodTable) 57 | } 58 | -------------------------------------------------------------------------------- /transform_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | ) 7 | 8 | func testStringTransformer(t *testing.T, f func(string) string) { 9 | transformer := TransformString(f) 10 | 11 | tests := []string{ 12 | "hello my name is", 13 | "where are you from", 14 | "does that matter?", 15 | } 16 | 17 | for _, tt := range tests { 18 | if expected, got := f(tt), transformer(tt); expected != got { 19 | t.Errorf("TransformString transformer failed to transform the answer, expected '%s' but got '%s'.", expected, got) 20 | } 21 | } 22 | } 23 | 24 | func TestTransformString(t *testing.T) { 25 | testStringTransformer(t, strings.ToTitle) // all letters titled 26 | testStringTransformer(t, strings.ToLower) // all letters lowercase 27 | } 28 | 29 | func TestComposeTransformers(t *testing.T) { 30 | // create a transformer which makes no sense, 31 | // remember: transformer can be used for any type 32 | // we just test the built'n functions that 33 | // happens to be for strings only. 34 | transformer := ComposeTransformers( 35 | Title, 36 | ToLower, 37 | ) 38 | 39 | ans := "my name is" 40 | if expected, got := strings.ToLower(ans), transformer(ans); expected != got { 41 | // the result should be lowercase. 42 | t.Errorf("TestComposeTransformers transformer failed to transform the answer to title->lowercase, expected '%s' but got '%s'.", expected, got) 43 | } 44 | 45 | var emptyAns string 46 | if expected, got := "", transformer(emptyAns); expected != got { 47 | // TransformString transformers should be skipped and return zero value string 48 | t.Errorf("TestComposeTransformers transformer failed to skip transforming on optional empty input, expected '%s' but got '%s'.", expected, got) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/select.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/AlecAivazis/survey/v2" 7 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 8 | ) 9 | 10 | var answer = "" 11 | 12 | var goodTable = []TestUtil.TestTableEntry{ 13 | { 14 | "standard", &survey.Select{ 15 | Message: "Choose a color:", 16 | Options: []string{"red", "blue", "green"}, 17 | }, &answer, nil, 18 | }, 19 | { 20 | "short", &survey.Select{ 21 | Message: "Choose a color:", 22 | Options: []string{"red", "blue"}, 23 | }, &answer, nil, 24 | }, 25 | { 26 | "default", &survey.Select{ 27 | Message: "Choose a color (should default blue):", 28 | Options: []string{"red", "blue", "green"}, 29 | Default: "blue", 30 | }, &answer, nil, 31 | }, 32 | { 33 | "one", &survey.Select{ 34 | Message: "Choose one:", 35 | Options: []string{"hello"}, 36 | }, &answer, nil, 37 | }, 38 | { 39 | "no help, type ?", &survey.Select{ 40 | Message: "Choose a color:", 41 | Options: []string{"red", "blue"}, 42 | }, &answer, nil, 43 | }, 44 | { 45 | "passes through bottom", &survey.Select{ 46 | Message: "Choose one:", 47 | Options: []string{"red", "blue"}, 48 | }, &answer, nil, 49 | }, 50 | { 51 | "passes through top", &survey.Select{ 52 | Message: "Choose one:", 53 | Options: []string{"red", "blue"}, 54 | }, &answer, nil, 55 | }, 56 | { 57 | "can navigate with j/k", &survey.Select{ 58 | Message: "Choose one:", 59 | Options: []string{"red", "blue", "green"}, 60 | }, &answer, nil, 61 | }, 62 | } 63 | 64 | var badTable = []TestUtil.TestTableEntry{ 65 | { 66 | "no options", &survey.Select{ 67 | Message: "Choose one:", 68 | }, &answer, nil, 69 | }, 70 | } 71 | 72 | func main() { 73 | TestUtil.RunTable(goodTable) 74 | TestUtil.RunErrorTable(badTable) 75 | } 76 | -------------------------------------------------------------------------------- /terminal/runereader_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestRuneWidthInvisible(t *testing.T) { 8 | var example rune = '⁣' 9 | expected := 0 10 | actual := runeWidth(example) 11 | if actual != expected { 12 | t.Errorf("Expected '%c' to have width %d, found %d", example, expected, actual) 13 | } 14 | } 15 | 16 | func TestRuneWidthNormal(t *testing.T) { 17 | var example rune = 'a' 18 | expected := 1 19 | actual := runeWidth(example) 20 | if actual != expected { 21 | t.Errorf("Expected '%c' to have width %d, found %d", example, expected, actual) 22 | } 23 | } 24 | 25 | func TestRuneWidthWide(t *testing.T) { 26 | var example rune = '错' 27 | expected := 2 28 | actual := runeWidth(example) 29 | if actual != expected { 30 | t.Errorf("Expected '%c' to have width %d, found %d", example, expected, actual) 31 | } 32 | } 33 | 34 | func TestStringWidthEmpty(t *testing.T) { 35 | example := "" 36 | expected := 0 37 | actual := StringWidth(example) 38 | if actual != expected { 39 | t.Errorf("Expected '%s' to have width %d, found %d", example, expected, actual) 40 | } 41 | } 42 | 43 | func TestStringWidthNormal(t *testing.T) { 44 | example := "Green" 45 | expected := 5 46 | actual := StringWidth(example) 47 | if actual != expected { 48 | t.Errorf("Expected '%s' to have width %d, found %d", example, expected, actual) 49 | } 50 | } 51 | 52 | func TestStringWidthFormat(t *testing.T) { 53 | example := "\033[31mRed\033[0m" 54 | expected := 3 55 | actual := StringWidth(example) 56 | if actual != expected { 57 | t.Errorf("Expected '%s' to have width %d, found %d", example, expected, actual) 58 | } 59 | 60 | example = "\033[1;34mbold\033[21mblue\033[0m" 61 | expected = 8 62 | actual = StringWidth(example) 63 | if actual != expected { 64 | t.Errorf("Expected '%s' to have width %d, found %d", example, expected, actual) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/editor.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/AlecAivazis/survey/v2" 10 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 11 | ) 12 | 13 | var answer = "" 14 | 15 | var goodTable = []TestUtil.TestTableEntry{ 16 | { 17 | "should open in editor", &survey.Editor{ 18 | Message: "should open", 19 | }, &answer, nil, 20 | }, 21 | { 22 | "has help", &survey.Editor{ 23 | Message: "press ? to see message", 24 | Help: "Does this work?", 25 | }, &answer, nil, 26 | }, 27 | { 28 | "should not include the default value in the prompt", &survey.Editor{ 29 | Message: "the default value 'Hello World' should not include in the prompt", 30 | HideDefault: true, 31 | Default: "Hello World", 32 | }, &answer, nil, 33 | }, 34 | { 35 | "should write the default value to the temporary file before the launch of the editor", &survey.Editor{ 36 | Message: "the default value 'Hello World' is written to the temporary file before the launch of the editor", 37 | AppendDefault: true, 38 | Default: "Hello World", 39 | }, &answer, nil, 40 | }, 41 | { 42 | Name: "should print the validation error, and recall the submitted invalid value instead of the default", 43 | Prompt: &survey.Editor{ 44 | Message: "the default value 'Hello World' is written to the temporary file before the launch of the editor", 45 | AppendDefault: true, 46 | Default: `this is the default value. change it to something containing "invalid" (in vi type "ccinvalidZZ")`, 47 | }, 48 | Value: &answer, 49 | Validate: func(v interface{}) error { 50 | s := v.(string) 51 | if strings.Contains(s, "invalid") { 52 | return fmt.Errorf(`this is the error message. change the input to something not containing "invalid"`) 53 | } 54 | return nil 55 | }, 56 | }, 57 | } 58 | 59 | func main() { 60 | TestUtil.RunTable(goodTable) 61 | } 62 | -------------------------------------------------------------------------------- /tests/multiselect.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | TestUtil "github.com/AlecAivazis/survey/v2/tests/util" 10 | ) 11 | 12 | var answer = []string{} 13 | 14 | var table = []TestUtil.TestTableEntry{ 15 | { 16 | "standard", &survey.MultiSelect{ 17 | Message: "What days do you prefer:", 18 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 19 | }, &answer, nil, 20 | }, 21 | { 22 | "default (sunday, tuesday)", &survey.MultiSelect{ 23 | Message: "What days do you prefer:", 24 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 25 | Default: []string{"Sunday", "Tuesday"}, 26 | }, &answer, nil, 27 | }, 28 | { 29 | "default not found", &survey.MultiSelect{ 30 | Message: "What days do you prefer:", 31 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 32 | Default: []string{"Sundayaa"}, 33 | }, &answer, nil, 34 | }, 35 | { 36 | "no help - type ?", &survey.MultiSelect{ 37 | Message: "What days do you prefer:", 38 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 39 | Default: []string{"Sundayaa"}, 40 | }, &answer, nil, 41 | }, 42 | { 43 | "can navigate with j/k", &survey.MultiSelect{ 44 | Message: "What days do you prefer:", 45 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 46 | Default: []string{"Sundayaa"}, 47 | }, &answer, nil, 48 | }, 49 | { 50 | "descriptions", &survey.MultiSelect{ 51 | Message: "What days do you prefer:", 52 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 53 | Description: func(value string, index int) string { 54 | return value + fmt.Sprint(index) 55 | 56 | }, 57 | }, &answer, nil, 58 | }, 59 | } 60 | 61 | func main() { 62 | TestUtil.RunTable(table) 63 | } 64 | -------------------------------------------------------------------------------- /renderer_posix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package survey 5 | 6 | import ( 7 | "bytes" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/AlecAivazis/survey/v2/terminal" 12 | pseudotty "github.com/creack/pty" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestRenderer_countLines(t *testing.T) { 18 | t.Parallel() 19 | 20 | termWidth := 72 21 | pty, tty, err := pseudotty.Open() 22 | require.Nil(t, err) 23 | defer pty.Close() 24 | defer tty.Close() 25 | 26 | err = pseudotty.Setsize(tty, &pseudotty.Winsize{ 27 | Rows: 30, 28 | Cols: uint16(termWidth), 29 | }) 30 | require.Nil(t, err) 31 | 32 | r := Renderer{ 33 | stdio: terminal.Stdio{ 34 | In: tty, 35 | Out: tty, 36 | Err: tty, 37 | }, 38 | } 39 | 40 | tests := []struct { 41 | name string 42 | buf *bytes.Buffer 43 | wants int 44 | }{ 45 | { 46 | name: "empty", 47 | buf: new(bytes.Buffer), 48 | wants: 0, 49 | }, 50 | { 51 | name: "no newline", 52 | buf: bytes.NewBufferString("hello"), 53 | wants: 0, 54 | }, 55 | { 56 | name: "short line", 57 | buf: bytes.NewBufferString("hello\n"), 58 | wants: 1, 59 | }, 60 | { 61 | name: "three short lines", 62 | buf: bytes.NewBufferString("hello\nbeautiful\nworld\n"), 63 | wants: 3, 64 | }, 65 | { 66 | name: "full line", 67 | buf: bytes.NewBufferString(strings.Repeat("A", termWidth) + "\n"), 68 | wants: 1, 69 | }, 70 | { 71 | name: "overflow", 72 | buf: bytes.NewBufferString(strings.Repeat("A", termWidth+1) + "\n"), 73 | wants: 2, 74 | }, 75 | { 76 | name: "overflow fills 2nd line", 77 | buf: bytes.NewBufferString(strings.Repeat("A", termWidth*2) + "\n"), 78 | wants: 2, 79 | }, 80 | { 81 | name: "overflow spills to 3rd line", 82 | buf: bytes.NewBufferString(strings.Repeat("A", termWidth*2+1) + "\n"), 83 | wants: 3, 84 | }, 85 | } 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | n := r.countLines(*tt.buf) 89 | assert.Equal(t, tt.wants, n) 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /password_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/AlecAivazis/survey/v2/core" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func init() { 12 | // disable color output for all prompts to simplify testing 13 | core.DisableColor = true 14 | } 15 | 16 | func TestPasswordRender(t *testing.T) { 17 | 18 | tests := []struct { 19 | title string 20 | prompt Password 21 | data PasswordTemplateData 22 | expected string 23 | }{ 24 | { 25 | "Test Password question output", 26 | Password{Message: "Tell me your secret:"}, 27 | PasswordTemplateData{}, 28 | fmt.Sprintf("%s Tell me your secret: ", defaultIcons().Question.Text), 29 | }, 30 | { 31 | "Test Password question output with help hidden", 32 | Password{Message: "Tell me your secret:", Help: "This is helpful"}, 33 | PasswordTemplateData{}, 34 | fmt.Sprintf("%s Tell me your secret: [%s for help] ", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput)), 35 | }, 36 | { 37 | "Test Password question output with help shown", 38 | Password{Message: "Tell me your secret:", Help: "This is helpful"}, 39 | PasswordTemplateData{ShowHelp: true}, 40 | fmt.Sprintf("%s This is helpful\n%s Tell me your secret: ", defaultIcons().Help.Text, defaultIcons().Question.Text), 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | test.data.Password = test.prompt 46 | 47 | // set the icon set 48 | test.data.Config = defaultPromptConfig() 49 | 50 | actual, _, err := core.RunTemplate( 51 | PasswordQuestionTemplate, 52 | &test.data, 53 | ) 54 | assert.Nil(t, err, test.title) 55 | assert.Equal(t, test.expected, actual, test.title) 56 | } 57 | } 58 | 59 | func TestPasswordPrompt(t *testing.T) { 60 | tests := []PromptTest{ 61 | { 62 | "Test Password prompt interaction", 63 | &Password{ 64 | Message: "Please type your password", 65 | }, 66 | func(c expectConsole) { 67 | c.ExpectString("Please type your password") 68 | c.Send("secret") 69 | c.SendLine("") 70 | c.ExpectEOF() 71 | }, 72 | "secret", 73 | }, 74 | { 75 | "Test Password prompt interaction with help", 76 | &Password{ 77 | Message: "Please type your password", 78 | Help: "It's a secret", 79 | }, 80 | func(c expectConsole) { 81 | c.ExpectString("Please type your password") 82 | c.SendLine("?") 83 | c.ExpectString("It's a secret") 84 | c.Send("secret") 85 | c.SendLine("") 86 | c.ExpectEOF() 87 | }, 88 | "secret", 89 | }, 90 | } 91 | 92 | for _, test := range tests { 93 | t.Run(test.name, func(t *testing.T) { 94 | RunPromptTest(t, test) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /transform.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "golang.org/x/text/cases" 8 | "golang.org/x/text/language" 9 | ) 10 | 11 | // TransformString returns a `Transformer` based on the "f" 12 | // function which accepts a string representation of the answer 13 | // and returns a new one, transformed, answer. 14 | // Take for example the functions inside the std `strings` package, 15 | // they can be converted to a compatible `Transformer` by using this function, 16 | // i.e: `TransformString(strings.Title)`, `TransformString(strings.ToUpper)`. 17 | // 18 | // Note that `TransformString` is just a helper, `Transformer` can be used 19 | // to transform any type of answer. 20 | func TransformString(f func(s string) string) Transformer { 21 | return func(ans interface{}) interface{} { 22 | // if the answer value passed in is the zero value of the appropriate type 23 | if isZero(reflect.ValueOf(ans)) { 24 | // skip this `Transformer` by returning a zero value of string. 25 | // The original answer will be not affected, 26 | // see survey.go#L125. 27 | // A zero value of string should be returned to be handled by 28 | // next Transformer in a composed Tranformer, 29 | // see tranform.go#L75 30 | return "" 31 | } 32 | 33 | // "ans" is never nil here, so we don't have to check that 34 | // see survey.go#L338 for more. 35 | // Make sure that the the answer's value was a typeof string. 36 | s, ok := ans.(string) 37 | if !ok { 38 | return "" 39 | } 40 | 41 | return f(s) 42 | } 43 | } 44 | 45 | // ToLower is a `Transformer`. 46 | // It receives an answer value 47 | // and returns a copy of the "ans" 48 | // with all Unicode letters mapped to their lower case. 49 | // 50 | // Note that if "ans" is not a string then it will 51 | // return a nil value, meaning that the above answer 52 | // will not be affected by this call at all. 53 | func ToLower(ans interface{}) interface{} { 54 | transformer := TransformString(strings.ToLower) 55 | return transformer(ans) 56 | } 57 | 58 | // Title is a `Transformer`. 59 | // It receives an answer value 60 | // and returns a copy of the "ans" 61 | // with all Unicode letters that begin words 62 | // mapped to their title case. 63 | // 64 | // Note that if "ans" is not a string then it will 65 | // return a nil value, meaning that the above answer 66 | // will not be affected by this call at all. 67 | func Title(ans interface{}) interface{} { 68 | transformer := TransformString(cases.Title(language.English).String) 69 | return transformer(ans) 70 | } 71 | 72 | // ComposeTransformers is a variadic function used to create one transformer from many. 73 | func ComposeTransformers(transformers ...Transformer) Transformer { 74 | // return a transformer that calls each one sequentially 75 | return func(ans interface{}) interface{} { 76 | // execute each transformer 77 | for _, t := range transformers { 78 | ans = t(ans) 79 | } 80 | return ans 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /password.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/AlecAivazis/survey/v2/core" 8 | "github.com/AlecAivazis/survey/v2/terminal" 9 | ) 10 | 11 | /* 12 | Password is like a normal Input but the text shows up as *'s and there is no default. Response 13 | type is a string. 14 | 15 | password := "" 16 | prompt := &survey.Password{ Message: "Please type your password" } 17 | survey.AskOne(prompt, &password) 18 | */ 19 | type Password struct { 20 | Renderer 21 | Message string 22 | Help string 23 | } 24 | 25 | type PasswordTemplateData struct { 26 | Password 27 | ShowHelp bool 28 | Config *PromptConfig 29 | } 30 | 31 | // PasswordQuestionTemplate is a template with color formatting. See Documentation: https://github.com/mgutz/ansi#style-format 32 | var PasswordQuestionTemplate = ` 33 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 34 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 35 | {{- color "default+hb"}}{{ .Message }} {{color "reset"}} 36 | {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}` 37 | 38 | func (p *Password) Prompt(config *PromptConfig) (interface{}, error) { 39 | // render the question template 40 | userOut, _, err := core.RunTemplate( 41 | PasswordQuestionTemplate, 42 | PasswordTemplateData{ 43 | Password: *p, 44 | Config: config, 45 | }, 46 | ) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | if _, err := fmt.Fprint(terminal.NewAnsiStdout(p.Stdio().Out), userOut); err != nil { 52 | return "", err 53 | } 54 | 55 | rr := p.NewRuneReader() 56 | _ = rr.SetTermMode() 57 | defer func() { 58 | _ = rr.RestoreTermMode() 59 | }() 60 | 61 | // no help msg? Just return any response 62 | if p.Help == "" { 63 | line, err := rr.ReadLine(config.HideCharacter) 64 | return string(line), err 65 | } 66 | 67 | cursor := p.NewCursor() 68 | 69 | var line []rune 70 | // process answers looking for help prompt answer 71 | for { 72 | line, err = rr.ReadLine(config.HideCharacter) 73 | if err != nil { 74 | return string(line), err 75 | } 76 | 77 | if string(line) == config.HelpInput { 78 | // terminal will echo the \n so we need to jump back up one row 79 | cursor.PreviousLine(1) 80 | 81 | err = p.Render( 82 | PasswordQuestionTemplate, 83 | PasswordTemplateData{ 84 | Password: *p, 85 | ShowHelp: true, 86 | Config: config, 87 | }, 88 | ) 89 | if err != nil { 90 | return "", err 91 | } 92 | continue 93 | } 94 | 95 | break 96 | } 97 | 98 | lineStr := string(line) 99 | p.AppendRenderedText(strings.Repeat(string(config.HideCharacter), len(lineStr))) 100 | return lineStr, err 101 | } 102 | 103 | // Cleanup hides the string with a fixed number of characters. 104 | func (prompt *Password) Cleanup(config *PromptConfig, val interface{}) error { 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /multiline.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/AlecAivazis/survey/v2/terminal" 7 | ) 8 | 9 | type Multiline struct { 10 | Renderer 11 | Message string 12 | Default string 13 | Help string 14 | } 15 | 16 | // data available to the templates when processing 17 | type MultilineTemplateData struct { 18 | Multiline 19 | Answer string 20 | ShowAnswer bool 21 | ShowHelp bool 22 | Config *PromptConfig 23 | } 24 | 25 | // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format 26 | var MultilineQuestionTemplate = ` 27 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 28 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 29 | {{- color "default+hb"}}{{ .Message }} {{color "reset"}} 30 | {{- if .ShowAnswer}} 31 | {{- "\n"}}{{color "cyan"}}{{.Answer}}{{color "reset"}} 32 | {{- if .Answer }}{{ "\n" }}{{ end }} 33 | {{- else }} 34 | {{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} 35 | {{- color "cyan"}}[Enter 2 empty lines to finish]{{color "reset"}} 36 | {{- end}}` 37 | 38 | func (i *Multiline) Prompt(config *PromptConfig) (interface{}, error) { 39 | // render the template 40 | err := i.Render( 41 | MultilineQuestionTemplate, 42 | MultilineTemplateData{ 43 | Multiline: *i, 44 | Config: config, 45 | }, 46 | ) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | // start reading runes from the standard in 52 | rr := i.NewRuneReader() 53 | _ = rr.SetTermMode() 54 | defer func() { 55 | _ = rr.RestoreTermMode() 56 | }() 57 | 58 | cursor := i.NewCursor() 59 | 60 | multiline := make([]string, 0) 61 | 62 | emptyOnce := false 63 | // get the next line 64 | for { 65 | var line []rune 66 | line, err = rr.ReadLine(0) 67 | if err != nil { 68 | return string(line), err 69 | } 70 | 71 | if string(line) == "" { 72 | if emptyOnce { 73 | numLines := len(multiline) + 2 74 | cursor.PreviousLine(numLines) 75 | for j := 0; j < numLines; j++ { 76 | terminal.EraseLine(i.Stdio().Out, terminal.ERASE_LINE_ALL) 77 | cursor.NextLine(1) 78 | } 79 | cursor.PreviousLine(numLines) 80 | break 81 | } 82 | emptyOnce = true 83 | } else { 84 | emptyOnce = false 85 | } 86 | multiline = append(multiline, string(line)) 87 | } 88 | 89 | val := strings.Join(multiline, "\n") 90 | val = strings.TrimSpace(val) 91 | 92 | // if the line is empty 93 | if len(val) == 0 { 94 | // use the default value 95 | return i.Default, err 96 | } 97 | 98 | i.AppendRenderedText(val) 99 | return val, err 100 | } 101 | 102 | func (i *Multiline) Cleanup(config *PromptConfig, val interface{}) error { 103 | return i.Render( 104 | MultilineQuestionTemplate, 105 | MultilineTemplateData{ 106 | Multiline: *i, 107 | Answer: val.(string), 108 | ShowAnswer: true, 109 | Config: config, 110 | }, 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Survey 2 | 3 | 🎉🎉 First off, thanks for the interest in contributing to `survey`! 🎉🎉 4 | 5 | The following is a set of guidelines to follow when contributing to this package. These are not hard rules, please use common sense and feel free to propose changes to this document in a pull request. 6 | 7 | ## Code of Conduct 8 | 9 | This project and its contibutors are expected to uphold the [Go Community Code of Conduct](https://golang.org/conduct). By participating, you are expected to follow these guidelines. 10 | 11 | ## Getting help 12 | 13 | * [Open an issue](https://github.com/AlecAivazis/survey/issues/new/choose) 14 | * Reach out to `@AlecAivazis` or `@mislav` in the Gophers slack (please use only when urgent) 15 | 16 | ## Submitting a contribution 17 | 18 | When submitting a contribution, 19 | 20 | - Try to make a series of smaller changes instead of one large change 21 | - Provide a description of each change that you are proposing 22 | - Reference the issue addressed by your pull request (if there is one) 23 | - Document all new exported Go APIs 24 | - Update the project's README when applicable 25 | - Include unit tests if possible 26 | - Contributions with visual ramifications or interaction changes should be accompanied with an integration test—see below for details. 27 | 28 | ## Writing and running tests 29 | 30 | When submitting features, please add as many units tests as necessary to test both positive and negative cases. 31 | 32 | Integration tests for survey uses [go-expect](https://github.com/Netflix/go-expect) to expect a match on stdout and respond on stdin. Since `os.Stdout` in a `go test` process is not a TTY, you need a way to interpret terminal / ANSI escape sequences for things like `CursorLocation`. The stdin/stdout handled by `go-expect` is also multiplexed to a [virtual terminal](https://github.com/hinshun/vt10x). 33 | 34 | For example, you can extend the tests for Input by specifying the following test case: 35 | 36 | ```go 37 | { 38 | "Test Input prompt interaction", // Name of the test. 39 | &Input{ // An implementation of the survey.Prompt interface. 40 | Message: "What is your name?", 41 | }, 42 | func(c *expect.Console) { // An expect procedure. You can expect strings / regexps and 43 | c.ExpectString("What is your name?") // write back strings / bytes to its psuedoterminal for survey. 44 | c.SendLine("Johnny Appleseed") 45 | c.ExpectEOF() // Nothing is read from the tty without an expect, and once an 46 | // expectation is met, no further bytes are read. End your 47 | // procedure with `c.ExpectEOF()` to read until survey finishes. 48 | }, 49 | "Johnny Appleseed", // The expected result. 50 | } 51 | ``` 52 | 53 | If you want to write your own `go-expect` test from scratch, you'll need to instantiate a virtual terminal, 54 | multiplex it into an `*expect.Console`, and hook up its tty with survey's optional stdio. Please see `go-expect` 55 | [documentation](https://godoc.org/github.com/Netflix/go-expect) for more detail. 56 | -------------------------------------------------------------------------------- /core/template.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "sync" 7 | "text/template" 8 | 9 | "github.com/mgutz/ansi" 10 | ) 11 | 12 | // DisableColor can be used to make testing reliable 13 | var DisableColor = false 14 | 15 | var TemplateFuncsWithColor = map[string]interface{}{ 16 | // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format 17 | "color": ansi.ColorCode, 18 | } 19 | 20 | var TemplateFuncsNoColor = map[string]interface{}{ 21 | // Templates without Color formatting. For layout/ testing. 22 | "color": func(color string) string { 23 | return "" 24 | }, 25 | } 26 | 27 | // envColorDisabled returns if output colors are forbid by environment variables 28 | func envColorDisabled() bool { 29 | return os.Getenv("NO_COLOR") != "" || os.Getenv("CLICOLOR") == "0" 30 | } 31 | 32 | // envColorForced returns if output colors are forced from environment variables 33 | func envColorForced() bool { 34 | val, ok := os.LookupEnv("CLICOLOR_FORCE") 35 | return ok && val != "0" 36 | } 37 | 38 | // RunTemplate returns two formatted strings given a template and 39 | // the data it requires. The first string returned is generated for 40 | // user-facing output and may or may not contain ANSI escape codes 41 | // for colored output. The second string does not contain escape codes 42 | // and can be used by the renderer for layout purposes. 43 | func RunTemplate(tmpl string, data interface{}) (string, string, error) { 44 | tPair, err := GetTemplatePair(tmpl) 45 | if err != nil { 46 | return "", "", err 47 | } 48 | userBuf := bytes.NewBufferString("") 49 | err = tPair[0].Execute(userBuf, data) 50 | if err != nil { 51 | return "", "", err 52 | } 53 | layoutBuf := bytes.NewBufferString("") 54 | err = tPair[1].Execute(layoutBuf, data) 55 | if err != nil { 56 | return userBuf.String(), "", err 57 | } 58 | return userBuf.String(), layoutBuf.String(), err 59 | } 60 | 61 | var ( 62 | memoizedGetTemplate = map[string][2]*template.Template{} 63 | 64 | memoMutex = &sync.RWMutex{} 65 | ) 66 | 67 | // GetTemplatePair returns a pair of compiled templates where the 68 | // first template is generated for user-facing output and the 69 | // second is generated for use by the renderer. The second 70 | // template does not contain any color escape codes, whereas 71 | // the first template may or may not depending on DisableColor. 72 | func GetTemplatePair(tmpl string) ([2]*template.Template, error) { 73 | memoMutex.RLock() 74 | if t, ok := memoizedGetTemplate[tmpl]; ok { 75 | memoMutex.RUnlock() 76 | return t, nil 77 | } 78 | memoMutex.RUnlock() 79 | 80 | templatePair := [2]*template.Template{nil, nil} 81 | 82 | templateNoColor, err := template.New("prompt").Funcs(TemplateFuncsNoColor).Parse(tmpl) 83 | if err != nil { 84 | return [2]*template.Template{}, err 85 | } 86 | 87 | templatePair[1] = templateNoColor 88 | 89 | envColorHide := envColorDisabled() && !envColorForced() 90 | if DisableColor || envColorHide { 91 | templatePair[0] = templatePair[1] 92 | } else { 93 | templateWithColor, err := template.New("prompt").Funcs(TemplateFuncsWithColor).Parse(tmpl) 94 | templatePair[0] = templateWithColor 95 | if err != nil { 96 | return [2]*template.Template{}, err 97 | } 98 | } 99 | 100 | memoMutex.Lock() 101 | memoizedGetTemplate[tmpl] = templatePair 102 | memoMutex.Unlock() 103 | return templatePair, nil 104 | } 105 | -------------------------------------------------------------------------------- /terminal/runereader_posix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | // The terminal mode manipulation code is derived heavily from: 5 | // https://github.com/golang/crypto/blob/master/ssh/terminal/util.go: 6 | // Copyright 2011 The Go Authors. All rights reserved. 7 | // Use of this source code is governed by a BSD-style 8 | // license that can be found in the LICENSE file. 9 | 10 | package terminal 11 | 12 | import ( 13 | "bufio" 14 | "bytes" 15 | "fmt" 16 | "syscall" 17 | "unsafe" 18 | ) 19 | 20 | const ( 21 | normalKeypad = '[' 22 | applicationKeypad = 'O' 23 | ) 24 | 25 | type runeReaderState struct { 26 | term syscall.Termios 27 | reader *bufio.Reader 28 | buf *bytes.Buffer 29 | } 30 | 31 | func newRuneReaderState(input FileReader) runeReaderState { 32 | buf := new(bytes.Buffer) 33 | return runeReaderState{ 34 | reader: bufio.NewReader(&BufferedReader{ 35 | In: input, 36 | Buffer: buf, 37 | }), 38 | buf: buf, 39 | } 40 | } 41 | 42 | func (rr *RuneReader) Buffer() *bytes.Buffer { 43 | return rr.state.buf 44 | } 45 | 46 | // For reading runes we just want to disable echo. 47 | func (rr *RuneReader) SetTermMode() error { 48 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(rr.stdio.In.Fd()), ioctlReadTermios, uintptr(unsafe.Pointer(&rr.state.term)), 0, 0, 0); err != 0 { 49 | return err 50 | } 51 | 52 | newState := rr.state.term 53 | newState.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG 54 | // Because we are clearing canonical mode, we need to ensure VMIN & VTIME are 55 | // set to the values we expect. This combination puts things in standard 56 | // "blocking read" mode (see termios(3)). 57 | newState.Cc[syscall.VMIN] = 1 58 | newState.Cc[syscall.VTIME] = 0 59 | 60 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(rr.stdio.In.Fd()), ioctlWriteTermios, uintptr(unsafe.Pointer(&newState)), 0, 0, 0); err != 0 { 61 | return err 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func (rr *RuneReader) RestoreTermMode() error { 68 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(rr.stdio.In.Fd()), ioctlWriteTermios, uintptr(unsafe.Pointer(&rr.state.term)), 0, 0, 0); err != 0 { 69 | return err 70 | } 71 | return nil 72 | } 73 | 74 | // ReadRune Parse escape sequences such as ESC [ A for arrow keys. 75 | // See https://vt100.net/docs/vt102-ug/appendixc.html 76 | func (rr *RuneReader) ReadRune() (rune, int, error) { 77 | r, size, err := rr.state.reader.ReadRune() 78 | if err != nil { 79 | return r, size, err 80 | } 81 | 82 | if r != KeyEscape { 83 | return r, size, err 84 | } 85 | 86 | if rr.state.reader.Buffered() == 0 { 87 | // no more characters so must be `Esc` key 88 | return KeyEscape, 1, nil 89 | } 90 | 91 | r, size, err = rr.state.reader.ReadRune() 92 | if err != nil { 93 | return r, size, err 94 | } 95 | 96 | // ESC O ... or ESC [ ...? 97 | if r != normalKeypad && r != applicationKeypad { 98 | return r, size, fmt.Errorf("unexpected escape sequence from terminal: %q", []rune{KeyEscape, r}) 99 | } 100 | 101 | keypad := r 102 | 103 | r, size, err = rr.state.reader.ReadRune() 104 | if err != nil { 105 | return r, size, err 106 | } 107 | 108 | switch r { 109 | case 'A': // ESC [ A or ESC O A 110 | return KeyArrowUp, 1, nil 111 | case 'B': // ESC [ B or ESC O B 112 | return KeyArrowDown, 1, nil 113 | case 'C': // ESC [ C or ESC O C 114 | return KeyArrowRight, 1, nil 115 | case 'D': // ESC [ D or ESC O D 116 | return KeyArrowLeft, 1, nil 117 | case 'F': // ESC [ F or ESC O F 118 | return SpecialKeyEnd, 1, nil 119 | case 'H': // ESC [ H or ESC O H 120 | return SpecialKeyHome, 1, nil 121 | case '3': // ESC [ 3 122 | if keypad == normalKeypad { 123 | // discard the following '~' key from buffer 124 | _, _ = rr.state.reader.Discard(1) 125 | return SpecialKeyDelete, 1, nil 126 | } 127 | } 128 | 129 | // discard the following '~' key from buffer 130 | _, _ = rr.state.reader.Discard(1) 131 | return IgnoreKey, 1, nil 132 | } 133 | -------------------------------------------------------------------------------- /terminal/runereader_windows.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "syscall" 6 | "unsafe" 7 | ) 8 | 9 | var ( 10 | dll = syscall.NewLazyDLL("kernel32.dll") 11 | setConsoleMode = dll.NewProc("SetConsoleMode") 12 | getConsoleMode = dll.NewProc("GetConsoleMode") 13 | readConsoleInput = dll.NewProc("ReadConsoleInputW") 14 | ) 15 | 16 | const ( 17 | EVENT_KEY = 0x0001 18 | 19 | // key codes for arrow keys 20 | // https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx 21 | VK_DELETE = 0x2E 22 | VK_END = 0x23 23 | VK_HOME = 0x24 24 | VK_LEFT = 0x25 25 | VK_UP = 0x26 26 | VK_RIGHT = 0x27 27 | VK_DOWN = 0x28 28 | 29 | RIGHT_CTRL_PRESSED = 0x0004 30 | LEFT_CTRL_PRESSED = 0x0008 31 | 32 | ENABLE_ECHO_INPUT uint32 = 0x0004 33 | ENABLE_LINE_INPUT uint32 = 0x0002 34 | ENABLE_PROCESSED_INPUT uint32 = 0x0001 35 | ) 36 | 37 | type inputRecord struct { 38 | eventType uint16 39 | padding uint16 40 | event [16]byte 41 | } 42 | 43 | type keyEventRecord struct { 44 | bKeyDown int32 45 | wRepeatCount uint16 46 | wVirtualKeyCode uint16 47 | wVirtualScanCode uint16 48 | unicodeChar uint16 49 | wdControlKeyState uint32 50 | } 51 | 52 | type runeReaderState struct { 53 | term uint32 54 | } 55 | 56 | func newRuneReaderState(input FileReader) runeReaderState { 57 | return runeReaderState{} 58 | } 59 | 60 | func (rr *RuneReader) Buffer() *bytes.Buffer { 61 | return nil 62 | } 63 | 64 | func (rr *RuneReader) SetTermMode() error { 65 | r, _, err := getConsoleMode.Call(uintptr(rr.stdio.In.Fd()), uintptr(unsafe.Pointer(&rr.state.term))) 66 | // windows return 0 on error 67 | if r == 0 { 68 | return err 69 | } 70 | 71 | newState := rr.state.term 72 | newState &^= ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT 73 | r, _, err = setConsoleMode.Call(uintptr(rr.stdio.In.Fd()), uintptr(newState)) 74 | // windows return 0 on error 75 | if r == 0 { 76 | return err 77 | } 78 | return nil 79 | } 80 | 81 | func (rr *RuneReader) RestoreTermMode() error { 82 | r, _, err := setConsoleMode.Call(uintptr(rr.stdio.In.Fd()), uintptr(rr.state.term)) 83 | // windows return 0 on error 84 | if r == 0 { 85 | return err 86 | } 87 | return nil 88 | } 89 | 90 | func (rr *RuneReader) ReadRune() (rune, int, error) { 91 | ir := &inputRecord{} 92 | bytesRead := 0 93 | for { 94 | rv, _, e := readConsoleInput.Call(rr.stdio.In.Fd(), uintptr(unsafe.Pointer(ir)), 1, uintptr(unsafe.Pointer(&bytesRead))) 95 | // windows returns non-zero to indicate success 96 | if rv == 0 && e != nil { 97 | return 0, 0, e 98 | } 99 | 100 | if ir.eventType != EVENT_KEY { 101 | continue 102 | } 103 | 104 | // the event data is really a c struct union, so here we have to do an usafe 105 | // cast to put the data into the keyEventRecord (since we have already verified 106 | // above that this event does correspond to a key event 107 | key := (*keyEventRecord)(unsafe.Pointer(&ir.event[0])) 108 | // we only care about key down events 109 | if key.bKeyDown == 0 { 110 | continue 111 | } 112 | if key.wdControlKeyState&(LEFT_CTRL_PRESSED|RIGHT_CTRL_PRESSED) != 0 && key.unicodeChar == 'C' { 113 | return KeyInterrupt, bytesRead, nil 114 | } 115 | // not a normal character so look up the input sequence from the 116 | // virtual key code mappings (VK_*) 117 | if key.unicodeChar == 0 { 118 | switch key.wVirtualKeyCode { 119 | case VK_DOWN: 120 | return KeyArrowDown, bytesRead, nil 121 | case VK_LEFT: 122 | return KeyArrowLeft, bytesRead, nil 123 | case VK_RIGHT: 124 | return KeyArrowRight, bytesRead, nil 125 | case VK_UP: 126 | return KeyArrowUp, bytesRead, nil 127 | case VK_DELETE: 128 | return SpecialKeyDelete, bytesRead, nil 129 | case VK_HOME: 130 | return SpecialKeyHome, bytesRead, nil 131 | case VK_END: 132 | return SpecialKeyEnd, bytesRead, nil 133 | default: 134 | // not a virtual key that we care about so just continue on to 135 | // the next input key 136 | continue 137 | } 138 | } 139 | r := rune(key.unicodeChar) 140 | return r, bytesRead, nil 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /confirm.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | // Confirm is a regular text input that accept yes/no answers. Response type is a bool. 9 | type Confirm struct { 10 | Renderer 11 | Message string 12 | Default bool 13 | Help string 14 | } 15 | 16 | // data available to the templates when processing 17 | type ConfirmTemplateData struct { 18 | Confirm 19 | Answer string 20 | ShowHelp bool 21 | Config *PromptConfig 22 | } 23 | 24 | // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format 25 | var ConfirmQuestionTemplate = ` 26 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 27 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 28 | {{- color "default+hb"}}{{ .Message }} {{color "reset"}} 29 | {{- if .Answer}} 30 | {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} 31 | {{- else }} 32 | {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}} 33 | {{- color "white"}}{{if .Default}}(Y/n) {{else}}(y/N) {{end}}{{color "reset"}} 34 | {{- end}}` 35 | 36 | // the regex for answers 37 | var ( 38 | yesRx = regexp.MustCompile("^(?i:y(?:es)?)$") 39 | noRx = regexp.MustCompile("^(?i:n(?:o)?)$") 40 | ) 41 | 42 | func yesNo(t bool) string { 43 | if t { 44 | return "Yes" 45 | } 46 | return "No" 47 | } 48 | 49 | func (c *Confirm) getBool(showHelp bool, config *PromptConfig) (bool, error) { 50 | cursor := c.NewCursor() 51 | rr := c.NewRuneReader() 52 | _ = rr.SetTermMode() 53 | defer func() { 54 | _ = rr.RestoreTermMode() 55 | }() 56 | 57 | // start waiting for input 58 | for { 59 | line, err := rr.ReadLine(0) 60 | if err != nil { 61 | return false, err 62 | } 63 | // move back up a line to compensate for the \n echoed from terminal 64 | cursor.PreviousLine(1) 65 | val := string(line) 66 | 67 | // get the answer that matches the 68 | var answer bool 69 | switch { 70 | case yesRx.Match([]byte(val)): 71 | answer = true 72 | case noRx.Match([]byte(val)): 73 | answer = false 74 | case val == "": 75 | answer = c.Default 76 | case val == config.HelpInput && c.Help != "": 77 | err := c.Render( 78 | ConfirmQuestionTemplate, 79 | ConfirmTemplateData{ 80 | Confirm: *c, 81 | ShowHelp: true, 82 | Config: config, 83 | }, 84 | ) 85 | if err != nil { 86 | // use the default value and bubble up 87 | return c.Default, err 88 | } 89 | showHelp = true 90 | continue 91 | default: 92 | // we didnt get a valid answer, so print error and prompt again 93 | //lint:ignore ST1005 it should be fine for this error message to have punctuation 94 | if err := c.Error(config, fmt.Errorf("%q is not a valid answer, please try again.", val)); err != nil { 95 | return c.Default, err 96 | } 97 | err := c.Render( 98 | ConfirmQuestionTemplate, 99 | ConfirmTemplateData{ 100 | Confirm: *c, 101 | ShowHelp: showHelp, 102 | Config: config, 103 | }, 104 | ) 105 | if err != nil { 106 | // use the default value and bubble up 107 | return c.Default, err 108 | } 109 | continue 110 | } 111 | return answer, nil 112 | } 113 | } 114 | 115 | /* 116 | Prompt prompts the user with a simple text field and expects a reply followed 117 | by a carriage return. 118 | 119 | likesPie := false 120 | prompt := &survey.Confirm{ Message: "What is your name?" } 121 | survey.AskOne(prompt, &likesPie) 122 | */ 123 | func (c *Confirm) Prompt(config *PromptConfig) (interface{}, error) { 124 | // render the question template 125 | err := c.Render( 126 | ConfirmQuestionTemplate, 127 | ConfirmTemplateData{ 128 | Confirm: *c, 129 | Config: config, 130 | }, 131 | ) 132 | if err != nil { 133 | return "", err 134 | } 135 | 136 | // get input and return 137 | return c.getBool(false, config) 138 | } 139 | 140 | // Cleanup overwrite the line with the finalized formatted version 141 | func (c *Confirm) Cleanup(config *PromptConfig, val interface{}) error { 142 | // if the value was previously true 143 | ans := yesNo(val.(bool)) 144 | 145 | // render the template 146 | return c.Render( 147 | ConfirmQuestionTemplate, 148 | ConfirmTemplateData{ 149 | Confirm: *c, 150 | Answer: ans, 151 | Config: config, 152 | }, 153 | ) 154 | } 155 | -------------------------------------------------------------------------------- /confirm_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "testing" 9 | 10 | "github.com/AlecAivazis/survey/v2/core" 11 | "github.com/AlecAivazis/survey/v2/terminal" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func init() { 16 | // disable color output for all prompts to simplify testing 17 | core.DisableColor = true 18 | } 19 | 20 | func TestConfirmRender(t *testing.T) { 21 | 22 | tests := []struct { 23 | title string 24 | prompt Confirm 25 | data ConfirmTemplateData 26 | expected string 27 | }{ 28 | { 29 | "Test Confirm question output with default true", 30 | Confirm{Message: "Is pizza your favorite food?", Default: true}, 31 | ConfirmTemplateData{}, 32 | fmt.Sprintf("%s Is pizza your favorite food? (Y/n) ", defaultIcons().Question.Text), 33 | }, 34 | { 35 | "Test Confirm question output with default false", 36 | Confirm{Message: "Is pizza your favorite food?", Default: false}, 37 | ConfirmTemplateData{}, 38 | fmt.Sprintf("%s Is pizza your favorite food? (y/N) ", defaultIcons().Question.Text), 39 | }, 40 | { 41 | "Test Confirm answer output", 42 | Confirm{Message: "Is pizza your favorite food?"}, 43 | ConfirmTemplateData{Answer: "Yes"}, 44 | fmt.Sprintf("%s Is pizza your favorite food? Yes\n", defaultIcons().Question.Text), 45 | }, 46 | { 47 | "Test Confirm with help but help message is hidden", 48 | Confirm{Message: "Is pizza your favorite food?", Help: "This is helpful"}, 49 | ConfirmTemplateData{}, 50 | fmt.Sprintf("%s Is pizza your favorite food? [%s for help] (y/N) ", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput)), 51 | }, 52 | { 53 | "Test Confirm help output with help message shown", 54 | Confirm{Message: "Is pizza your favorite food?", Help: "This is helpful"}, 55 | ConfirmTemplateData{ShowHelp: true}, 56 | fmt.Sprintf("%s This is helpful\n%s Is pizza your favorite food? (y/N) ", defaultIcons().Help.Text, defaultIcons().Question.Text), 57 | }, 58 | } 59 | 60 | for _, test := range tests { 61 | t.Run(test.title, func(t *testing.T) { 62 | r, w, err := os.Pipe() 63 | assert.NoError(t, err) 64 | 65 | test.prompt.WithStdio(terminal.Stdio{Out: w}) 66 | test.data.Confirm = test.prompt 67 | 68 | // set the runtime config 69 | test.data.Config = defaultPromptConfig() 70 | 71 | err = test.prompt.Render( 72 | ConfirmQuestionTemplate, 73 | test.data, 74 | ) 75 | assert.NoError(t, err) 76 | 77 | assert.NoError(t, w.Close()) 78 | var buf bytes.Buffer 79 | _, err = io.Copy(&buf, r) 80 | assert.NoError(t, err) 81 | 82 | assert.Contains(t, buf.String(), test.expected) 83 | }) 84 | } 85 | } 86 | 87 | func TestConfirmPrompt(t *testing.T) { 88 | tests := []PromptTest{ 89 | { 90 | "Test Confirm prompt interaction", 91 | &Confirm{ 92 | Message: "Is pizza your favorite food?", 93 | }, 94 | func(c expectConsole) { 95 | c.ExpectString("Is pizza your favorite food? (y/N)") 96 | c.SendLine("n") 97 | c.ExpectEOF() 98 | }, 99 | false, 100 | }, 101 | { 102 | "Test Confirm prompt interaction with default", 103 | &Confirm{ 104 | Message: "Is pizza your favorite food?", 105 | Default: true, 106 | }, 107 | func(c expectConsole) { 108 | c.ExpectString("Is pizza your favorite food? (Y/n)") 109 | c.SendLine("") 110 | c.ExpectEOF() 111 | }, 112 | true, 113 | }, 114 | { 115 | "Test Confirm prompt interaction overriding default", 116 | &Confirm{ 117 | Message: "Is pizza your favorite food?", 118 | Default: true, 119 | }, 120 | func(c expectConsole) { 121 | c.ExpectString("Is pizza your favorite food? (Y/n)") 122 | c.SendLine("n") 123 | c.ExpectEOF() 124 | }, 125 | false, 126 | }, 127 | { 128 | "Test Confirm prompt interaction and prompt for help", 129 | &Confirm{ 130 | Message: "Is pizza your favorite food?", 131 | Help: "It probably is", 132 | }, 133 | func(c expectConsole) { 134 | c.ExpectString( 135 | fmt.Sprintf( 136 | "Is pizza your favorite food? [%s for help] (y/N)", 137 | string(defaultPromptConfig().HelpInput), 138 | ), 139 | ) 140 | c.SendLine("?") 141 | c.ExpectString("It probably is") 142 | c.SendLine("Y") 143 | c.ExpectEOF() 144 | }, 145 | true, 146 | }, 147 | } 148 | 149 | for _, test := range tests { 150 | t.Run(test.name, func(t *testing.T) { 151 | RunPromptTest(t, test) 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /validate.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/AlecAivazis/survey/v2/core" 9 | ) 10 | 11 | // Required does not allow an empty value 12 | func Required(val interface{}) error { 13 | // the reflect value of the result 14 | value := reflect.ValueOf(val) 15 | 16 | // if the value passed in is the zero value of the appropriate type 17 | if isZero(value) && value.Kind() != reflect.Bool { 18 | //lint:ignore ST1005 this error message should render as capitalized 19 | return errors.New("Value is required") 20 | } 21 | return nil 22 | } 23 | 24 | // MaxLength requires that the string is no longer than the specified value 25 | func MaxLength(length int) Validator { 26 | // return a validator that checks the length of the string 27 | return func(val interface{}) error { 28 | if str, ok := val.(string); ok { 29 | // if the string is longer than the given value 30 | if len([]rune(str)) > length { 31 | // yell loudly 32 | return fmt.Errorf("value is too long. Max length is %v", length) 33 | } 34 | } else { 35 | // otherwise we cannot convert the value into a string and cannot enforce length 36 | return fmt.Errorf("cannot enforce length on response of type %v", reflect.TypeOf(val).Name()) 37 | } 38 | 39 | // the input is fine 40 | return nil 41 | } 42 | } 43 | 44 | // MinLength requires that the string is longer or equal in length to the specified value 45 | func MinLength(length int) Validator { 46 | // return a validator that checks the length of the string 47 | return func(val interface{}) error { 48 | if str, ok := val.(string); ok { 49 | // if the string is shorter than the given value 50 | if len([]rune(str)) < length { 51 | // yell loudly 52 | return fmt.Errorf("value is too short. Min length is %v", length) 53 | } 54 | } else { 55 | // otherwise we cannot convert the value into a string and cannot enforce length 56 | return fmt.Errorf("cannot enforce length on response of type %v", reflect.TypeOf(val).Name()) 57 | } 58 | 59 | // the input is fine 60 | return nil 61 | } 62 | } 63 | 64 | // MaxItems requires that the list is no longer than the specified value 65 | func MaxItems(numberItems int) Validator { 66 | // return a validator that checks the length of the list 67 | return func(val interface{}) error { 68 | if list, ok := val.([]core.OptionAnswer); ok { 69 | // if the list is longer than the given value 70 | if len(list) > numberItems { 71 | // yell loudly 72 | return fmt.Errorf("value is too long. Max items is %v", numberItems) 73 | } 74 | } else { 75 | // otherwise we cannot convert the value into a list of answer and cannot enforce length 76 | return fmt.Errorf("cannot impose the length on something other than a list of answers") 77 | } 78 | // the input is fine 79 | return nil 80 | } 81 | } 82 | 83 | // MinItems requires that the list is longer or equal in length to the specified value 84 | func MinItems(numberItems int) Validator { 85 | // return a validator that checks the length of the list 86 | return func(val interface{}) error { 87 | if list, ok := val.([]core.OptionAnswer); ok { 88 | // if the list is shorter than the given value 89 | if len(list) < numberItems { 90 | // yell loudly 91 | return fmt.Errorf("value is too short. Min items is %v", numberItems) 92 | } 93 | } else { 94 | // otherwise we cannot convert the value into a list of answer and cannot enforce length 95 | return fmt.Errorf("cannot impose the length on something other than a list of answers") 96 | } 97 | // the input is fine 98 | return nil 99 | } 100 | } 101 | 102 | // ComposeValidators is a variadic function used to create one validator from many. 103 | func ComposeValidators(validators ...Validator) Validator { 104 | // return a validator that calls each one sequentially 105 | return func(val interface{}) error { 106 | // execute each validator 107 | for _, validator := range validators { 108 | // if the answer's value is not valid 109 | if err := validator(val); err != nil { 110 | // return the error 111 | return err 112 | } 113 | } 114 | // we passed all validators, the answer is valid 115 | return nil 116 | } 117 | } 118 | 119 | // isZero returns true if the passed value is the zero object 120 | func isZero(v reflect.Value) bool { 121 | switch v.Kind() { 122 | case reflect.Slice, reflect.Map: 123 | return v.Len() == 0 124 | } 125 | 126 | // compare the types directly with more general coverage 127 | return reflect.DeepEqual(v.Interface(), reflect.Zero(v.Type()).Interface()) 128 | } 129 | -------------------------------------------------------------------------------- /terminal/cursor_windows.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "syscall" 6 | "unsafe" 7 | ) 8 | 9 | var COORDINATE_SYSTEM_BEGIN Short = 0 10 | 11 | // shared variable to save the cursor location from CursorSave() 12 | var cursorLoc Coord 13 | 14 | type Cursor struct { 15 | In FileReader 16 | Out FileWriter 17 | } 18 | 19 | func (c *Cursor) Up(n int) error { 20 | return c.cursorMove(0, n) 21 | } 22 | 23 | func (c *Cursor) Down(n int) error { 24 | return c.cursorMove(0, -1*n) 25 | } 26 | 27 | func (c *Cursor) Forward(n int) error { 28 | return c.cursorMove(n, 0) 29 | } 30 | 31 | func (c *Cursor) Back(n int) error { 32 | return c.cursorMove(-1*n, 0) 33 | } 34 | 35 | // save the cursor location 36 | func (c *Cursor) Save() error { 37 | loc, err := c.Location(nil) 38 | if err != nil { 39 | return err 40 | } 41 | cursorLoc = *loc 42 | return nil 43 | } 44 | 45 | func (c *Cursor) Restore() error { 46 | handle := syscall.Handle(c.Out.Fd()) 47 | // restore it to the original position 48 | _, _, err := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursorLoc)))) 49 | return normalizeError(err) 50 | } 51 | 52 | func (cur Coord) CursorIsAtLineEnd(size *Coord) bool { 53 | return cur.X == size.X 54 | } 55 | 56 | func (cur Coord) CursorIsAtLineBegin() bool { 57 | return cur.X == 0 58 | } 59 | 60 | func (c *Cursor) cursorMove(x int, y int) error { 61 | handle := syscall.Handle(c.Out.Fd()) 62 | 63 | var csbi consoleScreenBufferInfo 64 | if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { 65 | return err 66 | } 67 | 68 | var cursor Coord 69 | cursor.X = csbi.cursorPosition.X + Short(x) 70 | cursor.Y = csbi.cursorPosition.Y + Short(y) 71 | 72 | _, _, err := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) 73 | return normalizeError(err) 74 | } 75 | 76 | func (c *Cursor) NextLine(n int) error { 77 | if err := c.Up(n); err != nil { 78 | return err 79 | } 80 | return c.HorizontalAbsolute(0) 81 | } 82 | 83 | func (c *Cursor) PreviousLine(n int) error { 84 | if err := c.Down(n); err != nil { 85 | return err 86 | } 87 | return c.HorizontalAbsolute(0) 88 | } 89 | 90 | // for comparability purposes between windows 91 | // in windows we don't have to print out a new line 92 | func (c *Cursor) MoveNextLine(cur *Coord, terminalSize *Coord) error { 93 | return c.NextLine(1) 94 | } 95 | 96 | func (c *Cursor) HorizontalAbsolute(x int) error { 97 | handle := syscall.Handle(c.Out.Fd()) 98 | 99 | var csbi consoleScreenBufferInfo 100 | if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { 101 | return err 102 | } 103 | 104 | var cursor Coord 105 | cursor.X = Short(x) 106 | cursor.Y = csbi.cursorPosition.Y 107 | 108 | if csbi.size.X < cursor.X { 109 | cursor.X = csbi.size.X 110 | } 111 | 112 | _, _, err := procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor)))) 113 | return normalizeError(err) 114 | } 115 | 116 | func (c *Cursor) Show() error { 117 | handle := syscall.Handle(c.Out.Fd()) 118 | 119 | var cci consoleCursorInfo 120 | if _, _, err := procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))); normalizeError(err) != nil { 121 | return err 122 | } 123 | cci.visible = 1 124 | 125 | _, _, err := procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) 126 | return normalizeError(err) 127 | } 128 | 129 | func (c *Cursor) Hide() error { 130 | handle := syscall.Handle(c.Out.Fd()) 131 | 132 | var cci consoleCursorInfo 133 | if _, _, err := procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))); normalizeError(err) != nil { 134 | return err 135 | } 136 | cci.visible = 0 137 | 138 | _, _, err := procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci))) 139 | return normalizeError(err) 140 | } 141 | 142 | func (c *Cursor) Location(buf *bytes.Buffer) (*Coord, error) { 143 | handle := syscall.Handle(c.Out.Fd()) 144 | 145 | var csbi consoleScreenBufferInfo 146 | if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { 147 | return nil, err 148 | } 149 | 150 | return &csbi.cursorPosition, nil 151 | } 152 | 153 | func (c *Cursor) Size(buf *bytes.Buffer) (*Coord, error) { 154 | handle := syscall.Handle(c.Out.Fd()) 155 | 156 | var csbi consoleScreenBufferInfo 157 | if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { 158 | return nil, err 159 | } 160 | // windows' coordinate system begins at (0, 0) 161 | csbi.size.X-- 162 | csbi.size.Y-- 163 | return &csbi.size, nil 164 | } 165 | -------------------------------------------------------------------------------- /multiline_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "testing" 9 | 10 | "github.com/AlecAivazis/survey/v2/core" 11 | "github.com/AlecAivazis/survey/v2/terminal" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func init() { 16 | // disable color output for all prompts to simplify testing 17 | core.DisableColor = true 18 | } 19 | 20 | func TestMultilineRender(t *testing.T) { 21 | 22 | tests := []struct { 23 | title string 24 | prompt Multiline 25 | data MultilineTemplateData 26 | expected string 27 | }{ 28 | { 29 | "Test Multiline question output without default", 30 | Multiline{Message: "What is your favorite month:"}, 31 | MultilineTemplateData{}, 32 | fmt.Sprintf("%s What is your favorite month: [Enter 2 empty lines to finish]", defaultIcons().Question.Text), 33 | }, 34 | { 35 | "Test Multiline question output with default", 36 | Multiline{Message: "What is your favorite month:", Default: "April"}, 37 | MultilineTemplateData{}, 38 | fmt.Sprintf("%s What is your favorite month: (April) [Enter 2 empty lines to finish]", defaultIcons().Question.Text), 39 | }, 40 | { 41 | "Test Multiline answer output", 42 | Multiline{Message: "What is your favorite month:"}, 43 | MultilineTemplateData{Answer: "October", ShowAnswer: true}, 44 | fmt.Sprintf("%s What is your favorite month: \nOctober", defaultIcons().Question.Text), 45 | }, 46 | { 47 | "Test Multiline question output without default but with help hidden", 48 | Multiline{Message: "What is your favorite month:", Help: "This is helpful"}, 49 | MultilineTemplateData{}, 50 | fmt.Sprintf("%s What is your favorite month: [Enter 2 empty lines to finish]", string(defaultPromptConfig().HelpInput)), 51 | }, 52 | { 53 | "Test Multiline question output with default and with help hidden", 54 | Multiline{Message: "What is your favorite month:", Default: "April", Help: "This is helpful"}, 55 | MultilineTemplateData{}, 56 | fmt.Sprintf("%s What is your favorite month: (April) [Enter 2 empty lines to finish]", string(defaultPromptConfig().HelpInput)), 57 | }, 58 | { 59 | "Test Multiline question output without default but with help shown", 60 | Multiline{Message: "What is your favorite month:", Help: "This is helpful"}, 61 | MultilineTemplateData{ShowHelp: true}, 62 | fmt.Sprintf("%s This is helpful\n%s What is your favorite month: [Enter 2 empty lines to finish]", defaultIcons().Help.Text, defaultIcons().Question.Text), 63 | }, 64 | { 65 | "Test Multiline question output with default and with help shown", 66 | Multiline{Message: "What is your favorite month:", Default: "April", Help: "This is helpful"}, 67 | MultilineTemplateData{ShowHelp: true}, 68 | fmt.Sprintf("%s This is helpful\n%s What is your favorite month: (April) [Enter 2 empty lines to finish]", defaultIcons().Help.Text, defaultIcons().Question.Text), 69 | }, 70 | } 71 | 72 | for _, test := range tests { 73 | t.Run(test.title, func(t *testing.T) { 74 | r, w, err := os.Pipe() 75 | assert.NoError(t, err) 76 | 77 | test.prompt.WithStdio(terminal.Stdio{Out: w}) 78 | test.data.Multiline = test.prompt 79 | // set the icon set 80 | test.data.Config = defaultPromptConfig() 81 | 82 | err = test.prompt.Render( 83 | MultilineQuestionTemplate, 84 | test.data, 85 | ) 86 | assert.NoError(t, err) 87 | 88 | assert.NoError(t, w.Close()) 89 | var buf bytes.Buffer 90 | _, err = io.Copy(&buf, r) 91 | assert.NoError(t, err) 92 | 93 | assert.Contains(t, buf.String(), test.expected, test.title) 94 | }) 95 | } 96 | } 97 | 98 | func TestMultilinePrompt(t *testing.T) { 99 | tests := []PromptTest{ 100 | { 101 | "Test Multiline prompt interaction", 102 | &Multiline{ 103 | Message: "What is your name?", 104 | }, 105 | func(c expectConsole) { 106 | c.ExpectString("What is your name?") 107 | c.SendLine("Larry Bird\nI guess...\nnot sure\n\n") 108 | c.ExpectEOF() 109 | }, 110 | "Larry Bird\nI guess...\nnot sure", 111 | }, 112 | { 113 | "Test Multiline prompt interaction with default", 114 | &Multiline{ 115 | Message: "What is your name?", 116 | Default: "Johnny Appleseed", 117 | }, 118 | func(c expectConsole) { 119 | c.ExpectString("What is your name?") 120 | c.SendLine("\n\n") 121 | c.ExpectEOF() 122 | }, 123 | "Johnny Appleseed", 124 | }, 125 | { 126 | "Test Multiline prompt interaction overriding default", 127 | &Multiline{ 128 | Message: "What is your name?", 129 | Default: "Johnny Appleseed", 130 | }, 131 | func(c expectConsole) { 132 | c.ExpectString("What is your name?") 133 | c.SendLine("Larry Bird\n\n") 134 | c.ExpectEOF() 135 | }, 136 | "Larry Bird", 137 | }, 138 | { 139 | "Test Multiline does not implement help interaction", 140 | &Multiline{ 141 | Message: "What is your name?", 142 | Help: "It might be Satoshi Nakamoto", 143 | }, 144 | func(c expectConsole) { 145 | c.ExpectString("What is your name?") 146 | c.SendLine("?") 147 | c.SendLine("Satoshi Nakamoto\n\n") 148 | c.ExpectEOF() 149 | }, 150 | "?\nSatoshi Nakamoto", 151 | }, 152 | } 153 | 154 | for _, test := range tests { 155 | t.Run(test.name, func(t *testing.T) { 156 | RunPromptTest(t, test) 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 2 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 3 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 4 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 9 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 10 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 11 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 12 | github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= 13 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 14 | github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= 15 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 16 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= 17 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 22 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 23 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 25 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 26 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 27 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 28 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 29 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 30 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 31 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 32 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 33 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 38 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 40 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 41 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 42 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 43 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 44 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 45 | golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= 46 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 47 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 48 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 49 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 50 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 54 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | -------------------------------------------------------------------------------- /renderer.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/AlecAivazis/survey/v2/core" 7 | "github.com/AlecAivazis/survey/v2/terminal" 8 | "golang.org/x/term" 9 | ) 10 | 11 | type Renderer struct { 12 | stdio terminal.Stdio 13 | renderedErrors bytes.Buffer 14 | renderedText bytes.Buffer 15 | } 16 | 17 | type ErrorTemplateData struct { 18 | Error error 19 | Icon Icon 20 | } 21 | 22 | var ErrorTemplate = `{{color .Icon.Format }}{{ .Icon.Text }} Sorry, your reply was invalid: {{ .Error.Error }}{{color "reset"}} 23 | ` 24 | 25 | func (r *Renderer) WithStdio(stdio terminal.Stdio) { 26 | r.stdio = stdio 27 | } 28 | 29 | func (r *Renderer) Stdio() terminal.Stdio { 30 | return r.stdio 31 | } 32 | 33 | func (r *Renderer) NewRuneReader() *terminal.RuneReader { 34 | return terminal.NewRuneReader(r.stdio) 35 | } 36 | 37 | func (r *Renderer) NewCursor() *terminal.Cursor { 38 | return &terminal.Cursor{ 39 | In: r.stdio.In, 40 | Out: r.stdio.Out, 41 | } 42 | } 43 | 44 | func (r *Renderer) Error(config *PromptConfig, invalid error) error { 45 | // cleanup the currently rendered errors 46 | r.resetPrompt(r.countLines(r.renderedErrors)) 47 | r.renderedErrors.Reset() 48 | 49 | // cleanup the rest of the prompt 50 | r.resetPrompt(r.countLines(r.renderedText)) 51 | r.renderedText.Reset() 52 | 53 | userOut, layoutOut, err := core.RunTemplate(ErrorTemplate, &ErrorTemplateData{ 54 | Error: invalid, 55 | Icon: config.Icons.Error, 56 | }) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | // send the message to the user 62 | if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil { 63 | return err 64 | } 65 | 66 | // add the printed text to the rendered error buffer so we can cleanup later 67 | r.appendRenderedError(layoutOut) 68 | 69 | return nil 70 | } 71 | 72 | func (r *Renderer) OffsetCursor(offset int) { 73 | cursor := r.NewCursor() 74 | for offset > 0 { 75 | cursor.PreviousLine(1) 76 | offset-- 77 | } 78 | } 79 | 80 | func (r *Renderer) Render(tmpl string, data interface{}) error { 81 | // cleanup the currently rendered text 82 | lineCount := r.countLines(r.renderedText) 83 | r.resetPrompt(lineCount) 84 | r.renderedText.Reset() 85 | 86 | // render the template summarizing the current state 87 | userOut, layoutOut, err := core.RunTemplate(tmpl, data) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // print the summary 93 | if _, err := fmt.Fprint(terminal.NewAnsiStdout(r.stdio.Out), userOut); err != nil { 94 | return err 95 | } 96 | 97 | // add the printed text to the rendered text buffer so we can cleanup later 98 | r.AppendRenderedText(layoutOut) 99 | 100 | // nothing went wrong 101 | return nil 102 | } 103 | 104 | func (r *Renderer) RenderWithCursorOffset(tmpl string, data IterableOpts, opts []core.OptionAnswer, idx int) error { 105 | cursor := r.NewCursor() 106 | cursor.Restore() // clear any accessibility offsetting 107 | 108 | if err := r.Render(tmpl, data); err != nil { 109 | return err 110 | } 111 | cursor.Save() 112 | 113 | offset := computeCursorOffset(MultiSelectQuestionTemplate, data, opts, idx, r.termWidthSafe()) 114 | r.OffsetCursor(offset) 115 | 116 | return nil 117 | } 118 | 119 | // appendRenderedError appends text to the renderer's error buffer 120 | // which is used to track what has been printed. It is not exported 121 | // as errors should only be displayed via Error(config, error). 122 | func (r *Renderer) appendRenderedError(text string) { 123 | r.renderedErrors.WriteString(text) 124 | } 125 | 126 | // AppendRenderedText appends text to the renderer's text buffer 127 | // which is used to track of what has been printed. The buffer is used 128 | // to calculate how many lines to erase before updating the prompt. 129 | func (r *Renderer) AppendRenderedText(text string) { 130 | r.renderedText.WriteString(text) 131 | } 132 | 133 | func (r *Renderer) resetPrompt(lines int) { 134 | // clean out current line in case tmpl didnt end in newline 135 | cursor := r.NewCursor() 136 | cursor.HorizontalAbsolute(0) 137 | terminal.EraseLine(r.stdio.Out, terminal.ERASE_LINE_ALL) 138 | // clean up what we left behind last time 139 | for i := 0; i < lines; i++ { 140 | cursor.PreviousLine(1) 141 | terminal.EraseLine(r.stdio.Out, terminal.ERASE_LINE_ALL) 142 | } 143 | } 144 | 145 | func (r *Renderer) termWidth() (int, error) { 146 | fd := int(r.stdio.Out.Fd()) 147 | termWidth, _, err := term.GetSize(fd) 148 | return termWidth, err 149 | } 150 | 151 | func (r *Renderer) termWidthSafe() int { 152 | w, err := r.termWidth() 153 | if err != nil || w == 0 { 154 | // if we got an error due to terminal.GetSize not being supported 155 | // on current platform then just assume a very wide terminal 156 | w = 10000 157 | } 158 | return w 159 | } 160 | 161 | // countLines will return the count of `\n` with the addition of any 162 | // lines that have wrapped due to narrow terminal width 163 | func (r *Renderer) countLines(buf bytes.Buffer) int { 164 | w := r.termWidthSafe() 165 | 166 | bufBytes := buf.Bytes() 167 | 168 | count := 0 169 | curr := 0 170 | for curr < len(bufBytes) { 171 | var delim int 172 | // read until the next newline or the end of the string 173 | relDelim := bytes.IndexRune(bufBytes[curr:], '\n') 174 | if relDelim != -1 { 175 | count += 1 // new line found, add it to the count 176 | delim = curr + relDelim 177 | } else { 178 | delim = len(bufBytes) // no new line found, read rest of text 179 | } 180 | 181 | str := string(bufBytes[curr:delim]) 182 | if lineWidth := terminal.StringWidth(str); lineWidth > w { 183 | // account for word wrapping 184 | count += lineWidth / w 185 | if (lineWidth % w) == 0 { 186 | // content whose width is exactly a multiplier of available width should not 187 | // count as having wrapped on the last line 188 | count -= 1 189 | } 190 | } 191 | curr = delim + 1 192 | } 193 | 194 | return count 195 | } 196 | -------------------------------------------------------------------------------- /terminal/cursor.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package terminal 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "fmt" 10 | "io" 11 | "regexp" 12 | "strconv" 13 | ) 14 | 15 | var COORDINATE_SYSTEM_BEGIN Short = 1 16 | 17 | var dsrPattern = regexp.MustCompile(`\x1b\[(\d+);(\d+)R$`) 18 | 19 | type Cursor struct { 20 | In FileReader 21 | Out FileWriter 22 | } 23 | 24 | // Up moves the cursor n cells to up. 25 | func (c *Cursor) Up(n int) error { 26 | _, err := fmt.Fprintf(c.Out, "\x1b[%dA", n) 27 | return err 28 | } 29 | 30 | // Down moves the cursor n cells to down. 31 | func (c *Cursor) Down(n int) error { 32 | _, err := fmt.Fprintf(c.Out, "\x1b[%dB", n) 33 | return err 34 | } 35 | 36 | // Forward moves the cursor n cells to right. 37 | func (c *Cursor) Forward(n int) error { 38 | _, err := fmt.Fprintf(c.Out, "\x1b[%dC", n) 39 | return err 40 | } 41 | 42 | // Back moves the cursor n cells to left. 43 | func (c *Cursor) Back(n int) error { 44 | _, err := fmt.Fprintf(c.Out, "\x1b[%dD", n) 45 | return err 46 | } 47 | 48 | // NextLine moves cursor to beginning of the line n lines down. 49 | func (c *Cursor) NextLine(n int) error { 50 | if err := c.Down(1); err != nil { 51 | return err 52 | } 53 | return c.HorizontalAbsolute(0) 54 | } 55 | 56 | // PreviousLine moves cursor to beginning of the line n lines up. 57 | func (c *Cursor) PreviousLine(n int) error { 58 | if err := c.Up(1); err != nil { 59 | return err 60 | } 61 | return c.HorizontalAbsolute(0) 62 | } 63 | 64 | // HorizontalAbsolute moves cursor horizontally to x. 65 | func (c *Cursor) HorizontalAbsolute(x int) error { 66 | _, err := fmt.Fprintf(c.Out, "\x1b[%dG", x) 67 | return err 68 | } 69 | 70 | // Show shows the cursor. 71 | func (c *Cursor) Show() error { 72 | _, err := fmt.Fprint(c.Out, "\x1b[?25h") 73 | return err 74 | } 75 | 76 | // Hide hide the cursor. 77 | func (c *Cursor) Hide() error { 78 | _, err := fmt.Fprint(c.Out, "\x1b[?25l") 79 | return err 80 | } 81 | 82 | // move moves the cursor to a specific x,y location. 83 | func (c *Cursor) move(x int, y int) error { 84 | _, err := fmt.Fprintf(c.Out, "\x1b[%d;%df", x, y) 85 | return err 86 | } 87 | 88 | // Save saves the current position 89 | func (c *Cursor) Save() error { 90 | _, err := fmt.Fprint(c.Out, "\x1b7") 91 | return err 92 | } 93 | 94 | // Restore restores the saved position of the cursor 95 | func (c *Cursor) Restore() error { 96 | _, err := fmt.Fprint(c.Out, "\x1b8") 97 | return err 98 | } 99 | 100 | // for comparability purposes between windows 101 | // in unix we need to print out a new line on some terminals 102 | func (c *Cursor) MoveNextLine(cur *Coord, terminalSize *Coord) error { 103 | if cur.Y == terminalSize.Y { 104 | if _, err := fmt.Fprintln(c.Out); err != nil { 105 | return err 106 | } 107 | } 108 | return c.NextLine(1) 109 | } 110 | 111 | // Location returns the current location of the cursor in the terminal 112 | func (c *Cursor) Location(buf *bytes.Buffer) (*Coord, error) { 113 | // ANSI escape sequence for DSR - Device Status Report 114 | // https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences 115 | if _, err := fmt.Fprint(c.Out, "\x1b[6n"); err != nil { 116 | return nil, err 117 | } 118 | 119 | // There may be input in Stdin prior to CursorLocation so make sure we don't 120 | // drop those bytes. 121 | var loc []int 122 | var match string 123 | for loc == nil { 124 | // Reports the cursor position (CPR) to the application as (as though typed at 125 | // the keyboard) ESC[n;mR, where n is the row and m is the column. 126 | reader := bufio.NewReader(c.In) 127 | text, err := reader.ReadSlice(byte('R')) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | loc = dsrPattern.FindStringIndex(string(text)) 133 | if loc == nil { 134 | // After reading slice to byte 'R', the bufio Reader may have read more 135 | // bytes into its internal buffer which will be discarded on next ReadSlice. 136 | // We create a temporary buffer to read the remaining buffered slice and 137 | // write them to output buffer. 138 | buffered := make([]byte, reader.Buffered()) 139 | _, err = io.ReadFull(reader, buffered) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | // Stdin contains R that doesn't match DSR, so pass the bytes along to 145 | // output buffer. 146 | buf.Write(text) 147 | buf.Write(buffered) 148 | } else { 149 | // Write the non-matching leading bytes to output buffer. 150 | buf.Write(text[:loc[0]]) 151 | 152 | // Save the matching bytes to extract the row and column of the cursor. 153 | match = string(text[loc[0]:loc[1]]) 154 | } 155 | } 156 | 157 | matches := dsrPattern.FindStringSubmatch(string(match)) 158 | if len(matches) != 3 { 159 | return nil, fmt.Errorf("incorrect number of matches: %d", len(matches)) 160 | } 161 | 162 | col, err := strconv.Atoi(matches[2]) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | row, err := strconv.Atoi(matches[1]) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | return &Coord{Short(col), Short(row)}, nil 173 | } 174 | 175 | func (cur Coord) CursorIsAtLineEnd(size *Coord) bool { 176 | return cur.X == size.X 177 | } 178 | 179 | func (cur Coord) CursorIsAtLineBegin() bool { 180 | return cur.X == COORDINATE_SYSTEM_BEGIN 181 | } 182 | 183 | // Size returns the height and width of the terminal. 184 | func (c *Cursor) Size(buf *bytes.Buffer) (*Coord, error) { 185 | // the general approach here is to move the cursor to the very bottom 186 | // of the terminal, ask for the current location and then move the 187 | // cursor back where we started 188 | 189 | // hide the cursor (so it doesn't blink when getting the size of the terminal) 190 | c.Hide() 191 | defer c.Show() 192 | 193 | // save the current location of the cursor 194 | c.Save() 195 | defer c.Restore() 196 | 197 | // move the cursor to the very bottom of the terminal 198 | c.move(999, 999) 199 | 200 | // ask for the current location 201 | bottom, err := c.Location(buf) 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | // since the bottom was calculated in the lower right corner, it 207 | // is the dimensions we are looking for 208 | return bottom, nil 209 | } 210 | -------------------------------------------------------------------------------- /validate_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | 7 | "github.com/AlecAivazis/survey/v2/core" 8 | ) 9 | 10 | func TestRequired_canSucceedOnPrimitiveTypes(t *testing.T) { 11 | // a string to test 12 | str := "hello" 13 | // if the string is not valid 14 | if valid := Required(str); valid != nil { 15 | // 16 | t.Error("Non null returned an error when one wasn't expected.") 17 | } 18 | } 19 | 20 | func TestRequired_canFailOnPrimitiveTypes(t *testing.T) { 21 | // a string to test 22 | str := "" 23 | // if the string is valid 24 | if notValid := Required(str); notValid == nil { 25 | // 26 | t.Error("Non null did not return an error when one was expected.") 27 | } 28 | } 29 | 30 | func TestRequired_canSucceedOnMap(t *testing.T) { 31 | // an non-empty map to test 32 | val := map[string]int{"hello": 1} 33 | // if the string is not valid 34 | if valid := Required(val); valid != nil { 35 | // 36 | t.Error("Non null returned an error when one wasn't expected.") 37 | } 38 | } 39 | 40 | func TestRequired_passesOnFalse(t *testing.T) { 41 | // a false value to pass to the validator 42 | val := false 43 | 44 | // if the boolean is invalid 45 | if notValid := Required(val); notValid != nil { 46 | // 47 | t.Error("False failed a required check.") 48 | } 49 | } 50 | 51 | func TestRequired_canFailOnMap(t *testing.T) { 52 | // an non-empty map to test 53 | val := map[string]int{} 54 | // if the string is valid 55 | if notValid := Required(val); notValid == nil { 56 | // 57 | t.Error("Non null did not return an error when one was expected.") 58 | } 59 | } 60 | 61 | func TestRequired_canSucceedOnLists(t *testing.T) { 62 | // a string to test 63 | str := []string{"hello"} 64 | // if the string is not valid 65 | if valid := Required(str); valid != nil { 66 | // 67 | t.Error("Non null returned an error when one wasn't expected.") 68 | } 69 | } 70 | 71 | func TestRequired_canFailOnLists(t *testing.T) { 72 | // a string to test 73 | str := []string{} 74 | // if the string is not valid 75 | if notValid := Required(str); notValid == nil { 76 | // 77 | t.Error("Non null did not return an error when one was expected.") 78 | } 79 | } 80 | 81 | const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 82 | 83 | func randString(n int) string { 84 | b := make([]byte, n) 85 | for i := range b { 86 | b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] 87 | } 88 | return string(b) 89 | } 90 | 91 | func TestMaxItems(t *testing.T) { 92 | // the list to test 93 | testList := []core.OptionAnswer{ 94 | {Value: "a", Index: 0}, 95 | {Value: "b", Index: 1}, 96 | {Value: "c", Index: 2}, 97 | {Value: "d", Index: 3}, 98 | {Value: "e", Index: 4}, 99 | {Value: "f", Index: 5}, 100 | } 101 | 102 | // validate the list 103 | if err := MaxItems(4)(testList); err == nil { 104 | t.Error("No error returned with input greater than 6 items.") 105 | } 106 | } 107 | 108 | func TestMinItems(t *testing.T) { 109 | // the list to test 110 | testList := []core.OptionAnswer{ 111 | {Value: "a", Index: 0}, 112 | {Value: "b", Index: 1}, 113 | {Value: "c", Index: 2}, 114 | {Value: "d", Index: 3}, 115 | {Value: "e", Index: 4}, 116 | {Value: "f", Index: 5}, 117 | } 118 | 119 | // validate the list 120 | if err := MinItems(10)(testList); err == nil { 121 | t.Error("No error returned with input less than 10 items.") 122 | } 123 | } 124 | 125 | func TestMaxLength(t *testing.T) { 126 | // the string to test 127 | testStr := randString(150) 128 | // validate the string 129 | if err := MaxLength(140)(testStr); err == nil { 130 | t.Error("No error returned with input greater than 150 characters.") 131 | } 132 | 133 | // emoji test 134 | emojiStr := "I😍Golang" 135 | // validate visible length with Maxlength 136 | if err := MaxLength(10)(emojiStr); err != nil { 137 | t.Errorf("Error returned with emoji containing 8 characters long input.") 138 | } 139 | } 140 | 141 | func TestMinLength(t *testing.T) { 142 | // validate the string 143 | if err := MinLength(12)(randString(10)); err == nil { 144 | t.Error("No error returned with input less than 12 characters.") 145 | } 146 | 147 | // emoji test 148 | emojiStr := "I😍Golang" 149 | // validate visibly 8 characters long string with MinLength 150 | if err := MinLength(10)(emojiStr); err == nil { 151 | t.Error("No error returned with emoji containing input less than 10 characters.") 152 | } 153 | } 154 | 155 | func TestMinLength_onInt(t *testing.T) { 156 | // validate the string 157 | if err := MinLength(12)(1); err == nil { 158 | t.Error("No error returned when enforcing length on int.") 159 | } 160 | } 161 | 162 | func TestMaxLength_onInt(t *testing.T) { 163 | // validate the string 164 | if err := MaxLength(12)(1); err == nil { 165 | t.Error("No error returned when enforcing length on int.") 166 | } 167 | } 168 | 169 | func TestComposeValidators_passes(t *testing.T) { 170 | // create a validator that requires a string of no more than 10 characters 171 | valid := ComposeValidators( 172 | Required, 173 | MaxLength(10), 174 | ) 175 | 176 | str := randString(12) 177 | // if a valid string fails 178 | if err := valid(str); err == nil { 179 | // the test failed 180 | t.Error("Composed validator did not pass. Wanted string less than 10 chars, passed in", str) 181 | } 182 | 183 | } 184 | 185 | func TestComposeValidators_failsOnFirstError(t *testing.T) { 186 | // create a validator that requires a string of no more than 10 characters 187 | valid := ComposeValidators( 188 | Required, 189 | MaxLength(10), 190 | ) 191 | 192 | // if an empty string passes 193 | if err := valid(""); err == nil { 194 | // the test failed 195 | t.Error("Composed validator did not fail on first test like expected.") 196 | } 197 | } 198 | 199 | func TestComposeValidators_failsOnSubsequentValidators(t *testing.T) { 200 | // create a validator that requires a string of no more than 10 characters 201 | valid := ComposeValidators( 202 | Required, 203 | MaxLength(10), 204 | ) 205 | 206 | str := randString(12) 207 | // if a string longer than 10 passes 208 | if err := valid(str); err == nil { 209 | // the test failed 210 | t.Error("Composed validator did not fail on second first test like expected. Should fail max length > 10 :", str) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /editor.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "runtime" 9 | 10 | "github.com/AlecAivazis/survey/v2/terminal" 11 | shellquote "github.com/kballard/go-shellquote" 12 | ) 13 | 14 | /* 15 | Editor launches an instance of the users preferred editor on a temporary file. 16 | The editor to use is determined by reading the $VISUAL or $EDITOR environment 17 | variables. If neither of those are present, notepad (on Windows) or vim 18 | (others) is used. 19 | The launch of the editor is triggered by the enter key. Since the response may 20 | be long, it will not be echoed as Input does, instead, it print . 21 | Response type is a string. 22 | 23 | message := "" 24 | prompt := &survey.Editor{ Message: "What is your commit message?" } 25 | survey.AskOne(prompt, &message) 26 | */ 27 | type Editor struct { 28 | Renderer 29 | Message string 30 | Default string 31 | Help string 32 | Editor string 33 | HideDefault bool 34 | AppendDefault bool 35 | FileName string 36 | } 37 | 38 | // data available to the templates when processing 39 | type EditorTemplateData struct { 40 | Editor 41 | Answer string 42 | ShowAnswer bool 43 | ShowHelp bool 44 | Config *PromptConfig 45 | } 46 | 47 | // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format 48 | var EditorQuestionTemplate = ` 49 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 50 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 51 | {{- color "default+hb"}}{{ .Message }} {{color "reset"}} 52 | {{- if .ShowAnswer}} 53 | {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} 54 | {{- else }} 55 | {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}} 56 | {{- if and .Default (not .HideDefault)}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} 57 | {{- color "cyan"}}[Enter to launch editor] {{color "reset"}} 58 | {{- end}}` 59 | 60 | var ( 61 | bom = []byte{0xef, 0xbb, 0xbf} 62 | editor = "vim" 63 | ) 64 | 65 | func init() { 66 | if runtime.GOOS == "windows" { 67 | editor = "notepad" 68 | } 69 | if v := os.Getenv("VISUAL"); v != "" { 70 | editor = v 71 | } else if e := os.Getenv("EDITOR"); e != "" { 72 | editor = e 73 | } 74 | } 75 | 76 | func (e *Editor) PromptAgain(config *PromptConfig, invalid interface{}, err error) (interface{}, error) { 77 | initialValue := invalid.(string) 78 | return e.prompt(initialValue, config) 79 | } 80 | 81 | func (e *Editor) Prompt(config *PromptConfig) (interface{}, error) { 82 | initialValue := "" 83 | if e.Default != "" && e.AppendDefault { 84 | initialValue = e.Default 85 | } 86 | return e.prompt(initialValue, config) 87 | } 88 | 89 | func (e *Editor) prompt(initialValue string, config *PromptConfig) (interface{}, error) { 90 | // render the template 91 | err := e.Render( 92 | EditorQuestionTemplate, 93 | EditorTemplateData{ 94 | Editor: *e, 95 | Config: config, 96 | }, 97 | ) 98 | if err != nil { 99 | return "", err 100 | } 101 | 102 | // start reading runes from the standard in 103 | rr := e.NewRuneReader() 104 | _ = rr.SetTermMode() 105 | defer func() { 106 | _ = rr.RestoreTermMode() 107 | }() 108 | 109 | cursor := e.NewCursor() 110 | cursor.Hide() 111 | defer cursor.Show() 112 | 113 | for { 114 | r, _, err := rr.ReadRune() 115 | if err != nil { 116 | return "", err 117 | } 118 | if r == '\r' || r == '\n' { 119 | break 120 | } 121 | if r == terminal.KeyInterrupt { 122 | return "", terminal.InterruptErr 123 | } 124 | if r == terminal.KeyEndTransmission { 125 | break 126 | } 127 | if string(r) == config.HelpInput && e.Help != "" { 128 | err = e.Render( 129 | EditorQuestionTemplate, 130 | EditorTemplateData{ 131 | Editor: *e, 132 | ShowHelp: true, 133 | Config: config, 134 | }, 135 | ) 136 | if err != nil { 137 | return "", err 138 | } 139 | } 140 | continue 141 | } 142 | 143 | // prepare the temp file 144 | pattern := e.FileName 145 | if pattern == "" { 146 | pattern = "survey*.txt" 147 | } 148 | f, err := ioutil.TempFile("", pattern) 149 | if err != nil { 150 | return "", err 151 | } 152 | defer func() { 153 | _ = os.Remove(f.Name()) 154 | }() 155 | 156 | // write utf8 BOM header 157 | // The reason why we do this is because notepad.exe on Windows determines the 158 | // encoding of an "empty" text file by the locale, for example, GBK in China, 159 | // while golang string only handles utf8 well. However, a text file with utf8 160 | // BOM header is not considered "empty" on Windows, and the encoding will then 161 | // be determined utf8 by notepad.exe, instead of GBK or other encodings. 162 | if _, err := f.Write(bom); err != nil { 163 | return "", err 164 | } 165 | 166 | // write initial value 167 | if _, err := f.WriteString(initialValue); err != nil { 168 | return "", err 169 | } 170 | 171 | // close the fd to prevent the editor unable to save file 172 | if err := f.Close(); err != nil { 173 | return "", err 174 | } 175 | 176 | // check is input editor exist 177 | if e.Editor != "" { 178 | editor = e.Editor 179 | } 180 | 181 | stdio := e.Stdio() 182 | 183 | args, err := shellquote.Split(editor) 184 | if err != nil { 185 | return "", err 186 | } 187 | args = append(args, f.Name()) 188 | 189 | // open the editor 190 | cmd := exec.Command(args[0], args[1:]...) 191 | cmd.Stdin = stdio.In 192 | cmd.Stdout = stdio.Out 193 | cmd.Stderr = stdio.Err 194 | cursor.Show() 195 | if err := cmd.Run(); err != nil { 196 | return "", err 197 | } 198 | 199 | // raw is a BOM-unstripped UTF8 byte slice 200 | raw, err := ioutil.ReadFile(f.Name()) 201 | if err != nil { 202 | return "", err 203 | } 204 | 205 | // strip BOM header 206 | text := string(bytes.TrimPrefix(raw, bom)) 207 | 208 | // check length, return default value on empty 209 | if len(text) == 0 && !e.AppendDefault { 210 | return e.Default, nil 211 | } 212 | 213 | return text, nil 214 | } 215 | 216 | func (e *Editor) Cleanup(config *PromptConfig, val interface{}) error { 217 | return e.Render( 218 | EditorQuestionTemplate, 219 | EditorTemplateData{ 220 | Editor: *e, 221 | Answer: "", 222 | ShowAnswer: true, 223 | Config: config, 224 | }, 225 | ) 226 | } 227 | -------------------------------------------------------------------------------- /terminal/output_windows.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "strconv" 8 | "strings" 9 | "syscall" 10 | "unsafe" 11 | 12 | "github.com/mattn/go-isatty" 13 | ) 14 | 15 | const ( 16 | foregroundBlue = 0x1 17 | foregroundGreen = 0x2 18 | foregroundRed = 0x4 19 | foregroundIntensity = 0x8 20 | foregroundMask = (foregroundRed | foregroundBlue | foregroundGreen | foregroundIntensity) 21 | backgroundBlue = 0x10 22 | backgroundGreen = 0x20 23 | backgroundRed = 0x40 24 | backgroundIntensity = 0x80 25 | backgroundMask = (backgroundRed | backgroundBlue | backgroundGreen | backgroundIntensity) 26 | ) 27 | 28 | type Writer struct { 29 | out FileWriter 30 | handle syscall.Handle 31 | orgAttr word 32 | } 33 | 34 | func NewAnsiStdout(out FileWriter) io.Writer { 35 | var csbi consoleScreenBufferInfo 36 | if !isatty.IsTerminal(out.Fd()) { 37 | return out 38 | } 39 | handle := syscall.Handle(out.Fd()) 40 | procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) 41 | return &Writer{out: out, handle: handle, orgAttr: csbi.attributes} 42 | } 43 | 44 | func NewAnsiStderr(out FileWriter) io.Writer { 45 | var csbi consoleScreenBufferInfo 46 | if !isatty.IsTerminal(out.Fd()) { 47 | return out 48 | } 49 | handle := syscall.Handle(out.Fd()) 50 | procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi))) 51 | return &Writer{out: out, handle: handle, orgAttr: csbi.attributes} 52 | } 53 | 54 | func (w *Writer) Write(data []byte) (n int, err error) { 55 | r := bytes.NewReader(data) 56 | 57 | for { 58 | var ch rune 59 | var size int 60 | ch, size, err = r.ReadRune() 61 | if err != nil { 62 | if err == io.EOF { 63 | err = nil 64 | } 65 | return 66 | } 67 | n += size 68 | 69 | switch ch { 70 | case '\x1b': 71 | size, err = w.handleEscape(r) 72 | n += size 73 | if err != nil { 74 | return 75 | } 76 | default: 77 | _, err = fmt.Fprint(w.out, string(ch)) 78 | if err != nil { 79 | return 80 | } 81 | } 82 | } 83 | } 84 | 85 | func (w *Writer) handleEscape(r *bytes.Reader) (n int, err error) { 86 | buf := make([]byte, 0, 10) 87 | buf = append(buf, "\x1b"...) 88 | 89 | var ch rune 90 | var size int 91 | // Check '[' continues after \x1b 92 | ch, size, err = r.ReadRune() 93 | if err != nil { 94 | if err == io.EOF { 95 | err = nil 96 | } 97 | fmt.Fprint(w.out, string(buf)) 98 | return 99 | } 100 | n += size 101 | if ch != '[' { 102 | fmt.Fprint(w.out, string(buf)) 103 | return 104 | } 105 | 106 | // Parse escape code 107 | var code rune 108 | argBuf := make([]byte, 0, 10) 109 | for { 110 | ch, size, err = r.ReadRune() 111 | if err != nil { 112 | if err == io.EOF { 113 | err = nil 114 | } 115 | fmt.Fprint(w.out, string(buf)) 116 | return 117 | } 118 | n += size 119 | if ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') { 120 | code = ch 121 | break 122 | } 123 | argBuf = append(argBuf, string(ch)...) 124 | } 125 | 126 | err = w.applyEscapeCode(buf, string(argBuf), code) 127 | return 128 | } 129 | 130 | func (w *Writer) applyEscapeCode(buf []byte, arg string, code rune) error { 131 | c := &Cursor{Out: w.out} 132 | 133 | switch arg + string(code) { 134 | case "?25h": 135 | return c.Show() 136 | case "?25l": 137 | return c.Hide() 138 | } 139 | 140 | if code >= 'A' && code <= 'G' { 141 | if n, err := strconv.Atoi(arg); err == nil { 142 | switch code { 143 | case 'A': 144 | return c.Up(n) 145 | case 'B': 146 | return c.Down(n) 147 | case 'C': 148 | return c.Forward(n) 149 | case 'D': 150 | return c.Back(n) 151 | case 'E': 152 | return c.NextLine(n) 153 | case 'F': 154 | return c.PreviousLine(n) 155 | case 'G': 156 | return c.HorizontalAbsolute(n) 157 | } 158 | } 159 | } 160 | 161 | switch code { 162 | case 'm': 163 | return w.applySelectGraphicRendition(arg) 164 | default: 165 | buf = append(buf, string(code)...) 166 | _, err := fmt.Fprint(w.out, string(buf)) 167 | return err 168 | } 169 | } 170 | 171 | // Original implementation: https://github.com/mattn/go-colorable 172 | func (w *Writer) applySelectGraphicRendition(arg string) error { 173 | if arg == "" { 174 | _, _, err := procSetConsoleTextAttribute.Call(uintptr(w.handle), uintptr(w.orgAttr)) 175 | return normalizeError(err) 176 | } 177 | 178 | var csbi consoleScreenBufferInfo 179 | if _, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(w.handle), uintptr(unsafe.Pointer(&csbi))); normalizeError(err) != nil { 180 | return err 181 | } 182 | attr := csbi.attributes 183 | 184 | for _, param := range strings.Split(arg, ";") { 185 | n, err := strconv.Atoi(param) 186 | if err != nil { 187 | continue 188 | } 189 | 190 | switch { 191 | case n == 0 || n == 100: 192 | attr = w.orgAttr 193 | case 1 <= n && n <= 5: 194 | attr |= foregroundIntensity 195 | case 30 <= n && n <= 37: 196 | attr = (attr & backgroundMask) 197 | if (n-30)&1 != 0 { 198 | attr |= foregroundRed 199 | } 200 | if (n-30)&2 != 0 { 201 | attr |= foregroundGreen 202 | } 203 | if (n-30)&4 != 0 { 204 | attr |= foregroundBlue 205 | } 206 | case 40 <= n && n <= 47: 207 | attr = (attr & foregroundMask) 208 | if (n-40)&1 != 0 { 209 | attr |= backgroundRed 210 | } 211 | if (n-40)&2 != 0 { 212 | attr |= backgroundGreen 213 | } 214 | if (n-40)&4 != 0 { 215 | attr |= backgroundBlue 216 | } 217 | case 90 <= n && n <= 97: 218 | attr = (attr & backgroundMask) 219 | attr |= foregroundIntensity 220 | if (n-90)&1 != 0 { 221 | attr |= foregroundRed 222 | } 223 | if (n-90)&2 != 0 { 224 | attr |= foregroundGreen 225 | } 226 | if (n-90)&4 != 0 { 227 | attr |= foregroundBlue 228 | } 229 | case 100 <= n && n <= 107: 230 | attr = (attr & foregroundMask) 231 | attr |= backgroundIntensity 232 | if (n-100)&1 != 0 { 233 | attr |= backgroundRed 234 | } 235 | if (n-100)&2 != 0 { 236 | attr |= backgroundGreen 237 | } 238 | if (n-100)&4 != 0 { 239 | attr |= backgroundBlue 240 | } 241 | } 242 | } 243 | 244 | _, _, err := procSetConsoleTextAttribute.Call(uintptr(w.handle), uintptr(attr)) 245 | return normalizeError(err) 246 | } 247 | 248 | func normalizeError(err error) error { 249 | if syserr, ok := err.(syscall.Errno); ok && syserr == 0 { 250 | return nil 251 | } 252 | return err 253 | } 254 | -------------------------------------------------------------------------------- /examples/longmulti.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | survey "github.com/AlecAivazis/survey/v2" 10 | ) 11 | 12 | // the questions to ask 13 | var multiQs = []*survey.Question{ 14 | { 15 | Name: "letter", 16 | Prompt: &survey.MultiSelect{ 17 | Message: "Choose one or more words :", 18 | Options: []string{ 19 | "Afghanistan", 20 | "Åland Islands", 21 | "Albania", 22 | "Algeria", 23 | "American Samoa", 24 | "AndorrA", 25 | "Angola", 26 | "Anguilla", 27 | "Antarctica", 28 | "Antigua and Barbuda", 29 | "Argentina", 30 | "Armenia", 31 | "Aruba", 32 | "Australia", 33 | "Austria", 34 | "Azerbaijan", 35 | "Bahamas", 36 | "Bahrain", 37 | "Bangladesh", 38 | "Barbados", 39 | "Belarus", 40 | "Belgium", 41 | "Belize", 42 | "Benin", 43 | "Bermuda", 44 | "Bhutan", 45 | "Bolivia", 46 | "Bosnia and Herzegovina", 47 | "Botswana", 48 | "Bouvet Island", 49 | "Brazil", 50 | "British Indian Ocean Territory", 51 | "Brunei Darussalam", 52 | "Bulgaria", 53 | "Burkina Faso", 54 | "Burundi", 55 | "Cambodia", 56 | "Cameroon", 57 | "Canada", 58 | "Cape Verde", 59 | "Cayman Islands", 60 | "Central African Republic", 61 | "Chad", 62 | "Chile", 63 | "China", 64 | "Christmas Island", 65 | "Cocos (Keeling) Islands", 66 | "Colombia", 67 | "Comoros", 68 | "Congo", 69 | "Congo, The Democratic Republic of the", 70 | "Cook Islands", 71 | "Costa Rica", 72 | "Cote D'Ivoire", 73 | "Croatia", 74 | "Cuba", 75 | "Cyprus", 76 | "Czech Republic", 77 | "Denmark", 78 | "Djibouti", 79 | "Dominica", 80 | "Dominican Republic", 81 | "Ecuador", 82 | "Egypt", 83 | "El Salvador", 84 | "Equatorial Guinea", 85 | "Eritrea", 86 | "Estonia", 87 | "Ethiopia", 88 | "Falkland Islands (Malvinas)", 89 | "Faroe Islands", 90 | "Fiji", 91 | "Finland", 92 | "France", 93 | "French Guiana", 94 | "French Polynesia", 95 | "French Southern Territories", 96 | "Gabon", 97 | "Gambia", 98 | "Georgia", 99 | "Germany", 100 | "Ghana", 101 | "Gibraltar", 102 | "Greece", 103 | "Greenland", 104 | "Grenada", 105 | "Guadeloupe", 106 | "Guam", 107 | "Guatemala", 108 | "Guernsey", 109 | "Guinea", 110 | "Guinea-Bissau", 111 | "Guyana", 112 | "Haiti", 113 | "Heard Island and Mcdonald Islands", 114 | "Holy See (Vatican City State)", 115 | "Honduras", 116 | "Hong Kong", 117 | "Hungary", 118 | "Iceland", 119 | "India", 120 | "Indonesia", 121 | "Iran, Islamic Republic Of", 122 | "Iraq", 123 | "Ireland", 124 | "Isle of Man", 125 | "Israel", 126 | "Italy", 127 | "Jamaica", 128 | "Japan", 129 | "Jersey", 130 | "Jordan", 131 | "Kazakhstan", 132 | "Kenya", 133 | "Kiribati", 134 | "Korea, Democratic People'S Republic of", 135 | "Korea, Republic of", 136 | "Kuwait", 137 | "Kyrgyzstan", 138 | "Lao People'S Democratic Republic", 139 | "Latvia", 140 | "Lebanon", 141 | "Lesotho", 142 | "Liberia", 143 | "Libyan Arab Jamahiriya", 144 | "Liechtenstein", 145 | "Lithuania", 146 | "Luxembourg", 147 | "Macao", 148 | "Macedonia, The Former Yugoslav Republic of", 149 | "Madagascar", 150 | "Malawi", 151 | "Malaysia", 152 | "Maldives", 153 | "Mali", 154 | "Malta", 155 | "Marshall Islands", 156 | "Martinique", 157 | "Mauritania", 158 | "Mauritius", 159 | "Mayotte", 160 | "Mexico", 161 | "Micronesia, Federated States of", 162 | "Moldova, Republic of", 163 | "Monaco", 164 | "Mongolia", 165 | "Montserrat", 166 | "Morocco", 167 | "Mozambique", 168 | "Myanmar", 169 | "Namibia", 170 | "Nauru", 171 | "Nepal", 172 | "Netherlands", 173 | "Netherlands Antilles", 174 | "New Caledonia", 175 | "New Zealand", 176 | "Nicaragua", 177 | "Niger", 178 | "Nigeria", 179 | "Niue", 180 | "Norfolk Island", 181 | "Northern Mariana Islands", 182 | "Norway", 183 | "Oman", 184 | "Pakistan", 185 | "Palau", 186 | "Palestinian Territory, Occupied", 187 | "Panama", 188 | "Papua New Guinea", 189 | "Paraguay", 190 | "Peru", 191 | "Philippines", 192 | "Pitcairn", 193 | "Poland", 194 | "Portugal", 195 | "Puerto Rico", 196 | "Qatar", 197 | "Reunion", 198 | "Romania", 199 | "Russian Federation", 200 | "RWANDA", 201 | "Saint Helena", 202 | "Saint Kitts and Nevis", 203 | "Saint Lucia", 204 | "Saint Pierre and Miquelon", 205 | "Saint Vincent and the Grenadines", 206 | "Samoa", 207 | "San Marino", 208 | "Sao Tome and Principe", 209 | "Saudi Arabia", 210 | "Senegal", 211 | "Serbia and Montenegro", 212 | "Seychelles", 213 | "Sierra Leone", 214 | "Singapore", 215 | "Slovakia", 216 | "Slovenia", 217 | "Solomon Islands", 218 | "Somalia", 219 | "South Africa", 220 | "South Georgia and the South Sandwich Islands", 221 | "Spain", 222 | "Sri Lanka", 223 | "Sudan", 224 | "Suriname", 225 | "Svalbard and Jan Mayen", 226 | "Swaziland", 227 | "Sweden", 228 | "Switzerland", 229 | "Syrian Arab Republic", 230 | "Taiwan, Province of China", 231 | "Tajikistan", 232 | "Tanzania, United Republic of", 233 | "Thailand", 234 | "Timor-Leste", 235 | "Togo", 236 | "Tokelau", 237 | "Tonga", 238 | "Trinidad and Tobago", 239 | "Tunisia", 240 | "Turkey", 241 | "Turkmenistan", 242 | "Turks and Caicos Islands", 243 | "Tuvalu", 244 | "Uganda", 245 | "Ukraine", 246 | "United Arab Emirates", 247 | "United Kingdom", 248 | "United States", 249 | "United States Minor Outlying Islands", 250 | "Uruguay", 251 | "Uzbekistan", 252 | "Vanuatu", 253 | "Venezuela", 254 | "Viet Nam", 255 | "Virgin Islands, British", 256 | "Virgin Islands, U.S.", 257 | "Wallis and Futuna", 258 | "Western Sahara", 259 | "Yemen", 260 | "Zambia", 261 | "Zimbabwe", 262 | }, 263 | }, 264 | }, 265 | } 266 | 267 | func main() { 268 | answers := []string{} 269 | 270 | // ask the question 271 | err := survey.Ask(multiQs, &answers) 272 | 273 | if err != nil { 274 | fmt.Println(err.Error()) 275 | return 276 | } 277 | // print the answers 278 | fmt.Printf("you chose: %s\n", strings.Join(answers, ", ")) 279 | } 280 | -------------------------------------------------------------------------------- /examples/countrylist.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/AlecAivazis/survey/v2" 9 | ) 10 | 11 | // the questions to ask 12 | var countryQs = []*survey.Question{ 13 | { 14 | Name: "country", 15 | Prompt: &survey.Select{ 16 | Message: "Choose a country:", 17 | Options: []string{ 18 | "Afghanistan", 19 | "Åland Islands", 20 | "Albania", 21 | "Algeria", 22 | "American Samoa", 23 | "AndorrA", 24 | "Angola", 25 | "Anguilla", 26 | "Antarctica", 27 | "Antigua and Barbuda", 28 | "Argentina", 29 | "Armenia", 30 | "Aruba", 31 | "Australia", 32 | "Austria", 33 | "Azerbaijan", 34 | "Bahamas", 35 | "Bahrain", 36 | "Bangladesh", 37 | "Barbados", 38 | "Belarus", 39 | "Belgium", 40 | "Belize", 41 | "Benin", 42 | "Bermuda", 43 | "Bhutan", 44 | "Bolivia", 45 | "Bosnia and Herzegovina", 46 | "Botswana", 47 | "Bouvet Island", 48 | "Brazil", 49 | "British Indian Ocean Territory", 50 | "Brunei Darussalam", 51 | "Bulgaria", 52 | "Burkina Faso", 53 | "Burundi", 54 | "Cambodia", 55 | "Cameroon", 56 | "Canada", 57 | "Cape Verde", 58 | "Cayman Islands", 59 | "Central African Republic", 60 | "Chad", 61 | "Chile", 62 | "China", 63 | "Christmas Island", 64 | "Cocos (Keeling) Islands", 65 | "Colombia", 66 | "Comoros", 67 | "Congo", 68 | "Congo, The Democratic Republic of the", 69 | "Cook Islands", 70 | "Costa Rica", 71 | "Cote D'Ivoire", 72 | "Croatia", 73 | "Cuba", 74 | "Cyprus", 75 | "Czech Republic", 76 | "Denmark", 77 | "Djibouti", 78 | "Dominica", 79 | "Dominican Republic", 80 | "Ecuador", 81 | "Egypt", 82 | "El Salvador", 83 | "Equatorial Guinea", 84 | "Eritrea", 85 | "Estonia", 86 | "Ethiopia", 87 | "Falkland Islands (Malvinas)", 88 | "Faroe Islands", 89 | "Fiji", 90 | "Finland", 91 | "France", 92 | "French Guiana", 93 | "French Polynesia", 94 | "French Southern Territories", 95 | "Gabon", 96 | "Gambia", 97 | "Georgia", 98 | "Germany", 99 | "Ghana", 100 | "Gibraltar", 101 | "Greece", 102 | "Greenland", 103 | "Grenada", 104 | "Guadeloupe", 105 | "Guam", 106 | "Guatemala", 107 | "Guernsey", 108 | "Guinea", 109 | "Guinea-Bissau", 110 | "Guyana", 111 | "Haiti", 112 | "Heard Island and Mcdonald Islands", 113 | "Holy See (Vatican City State)", 114 | "Honduras", 115 | "Hong Kong", 116 | "Hungary", 117 | "Iceland", 118 | "India", 119 | "Indonesia", 120 | "Iran, Islamic Republic Of", 121 | "Iraq", 122 | "Ireland", 123 | "Isle of Man", 124 | "Israel", 125 | "Italy", 126 | "Jamaica", 127 | "Japan", 128 | "Jersey", 129 | "Jordan", 130 | "Kazakhstan", 131 | "Kenya", 132 | "Kiribati", 133 | "Korea, Democratic People'S Republic of", 134 | "Korea, Republic of", 135 | "Kuwait", 136 | "Kyrgyzstan", 137 | "Lao People'S Democratic Republic", 138 | "Latvia", 139 | "Lebanon", 140 | "Lesotho", 141 | "Liberia", 142 | "Libyan Arab Jamahiriya", 143 | "Liechtenstein", 144 | "Lithuania", 145 | "Luxembourg", 146 | "Macao", 147 | "Macedonia, The Former Yugoslav Republic of", 148 | "Madagascar", 149 | "Malawi", 150 | "Malaysia", 151 | "Maldives", 152 | "Mali", 153 | "Malta", 154 | "Marshall Islands", 155 | "Martinique", 156 | "Mauritania", 157 | "Mauritius", 158 | "Mayotte", 159 | "Mexico", 160 | "Micronesia, Federated States of", 161 | "Moldova, Republic of", 162 | "Monaco", 163 | "Mongolia", 164 | "Montserrat", 165 | "Morocco", 166 | "Mozambique", 167 | "Myanmar", 168 | "Namibia", 169 | "Nauru", 170 | "Nepal", 171 | "Netherlands", 172 | "Netherlands Antilles", 173 | "New Caledonia", 174 | "New Zealand", 175 | "Nicaragua", 176 | "Niger", 177 | "Nigeria", 178 | "Niue", 179 | "Norfolk Island", 180 | "Northern Mariana Islands", 181 | "Norway", 182 | "Oman", 183 | "Pakistan", 184 | "Palau", 185 | "Palestinian Territory, Occupied", 186 | "Panama", 187 | "Papua New Guinea", 188 | "Paraguay", 189 | "Peru", 190 | "Philippines", 191 | "Pitcairn", 192 | "Poland", 193 | "Portugal", 194 | "Puerto Rico", 195 | "Qatar", 196 | "Reunion", 197 | "Romania", 198 | "Russian Federation", 199 | "RWANDA", 200 | "Saint Helena", 201 | "Saint Kitts and Nevis", 202 | "Saint Lucia", 203 | "Saint Pierre and Miquelon", 204 | "Saint Vincent and the Grenadines", 205 | "Samoa", 206 | "San Marino", 207 | "Sao Tome and Principe", 208 | "Saudi Arabia", 209 | "Senegal", 210 | "Serbia and Montenegro", 211 | "Seychelles", 212 | "Sierra Leone", 213 | "Singapore", 214 | "Slovakia", 215 | "Slovenia", 216 | "Solomon Islands", 217 | "Somalia", 218 | "South Africa", 219 | "South Georgia and the South Sandwich Islands", 220 | "Spain", 221 | "Sri Lanka", 222 | "Sudan", 223 | "Suriname", 224 | "Svalbard and Jan Mayen", 225 | "Swaziland", 226 | "Sweden", 227 | "Switzerland", 228 | "Syrian Arab Republic", 229 | "Taiwan, Province of China", 230 | "Tajikistan", 231 | "Tanzania, United Republic of", 232 | "Thailand", 233 | "Timor-Leste", 234 | "Togo", 235 | "Tokelau", 236 | "Tonga", 237 | "Trinidad and Tobago", 238 | "Tunisia", 239 | "Turkey", 240 | "Turkmenistan", 241 | "Turks and Caicos Islands", 242 | "Tuvalu", 243 | "Uganda", 244 | "Ukraine", 245 | "United Arab Emirates", 246 | "United Kingdom", 247 | "United States", 248 | "United States Minor Outlying Islands", 249 | "Uruguay", 250 | "Uzbekistan", 251 | "Vanuatu", 252 | "Venezuela", 253 | "Viet Nam", 254 | "Virgin Islands, British", 255 | "Virgin Islands, U.S.", 256 | "Wallis and Futuna", 257 | "Western Sahara", 258 | "Yemen", 259 | "Zambia", 260 | "Zimbabwe", 261 | }, 262 | }, 263 | Validate: survey.Required, 264 | }, 265 | } 266 | 267 | func main() { 268 | answers := struct { 269 | Country string 270 | }{} 271 | 272 | // ask the question 273 | err := survey.Ask(countryQs, &answers) 274 | 275 | if err != nil { 276 | fmt.Println(err.Error()) 277 | return 278 | } 279 | // print the answers 280 | fmt.Printf("you chose %s.\n", answers.Country) 281 | } 282 | -------------------------------------------------------------------------------- /examples/longmultikeepfilter.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | survey "github.com/AlecAivazis/survey/v2" 10 | ) 11 | 12 | // the questions to ask 13 | var multiQs = []*survey.Question{ 14 | { 15 | Name: "letter", 16 | Prompt: &survey.MultiSelect{ 17 | Message: "Choose one or more words :", 18 | Options: []string{ 19 | "Afghanistan", 20 | "Åland Islands", 21 | "Albania", 22 | "Algeria", 23 | "American Samoa", 24 | "AndorrA", 25 | "Angola", 26 | "Anguilla", 27 | "Antarctica", 28 | "Antigua and Barbuda", 29 | "Argentina", 30 | "Armenia", 31 | "Aruba", 32 | "Australia", 33 | "Austria", 34 | "Azerbaijan", 35 | "Bahamas", 36 | "Bahrain", 37 | "Bangladesh", 38 | "Barbados", 39 | "Belarus", 40 | "Belgium", 41 | "Belize", 42 | "Benin", 43 | "Bermuda", 44 | "Bhutan", 45 | "Bolivia", 46 | "Bosnia and Herzegovina", 47 | "Botswana", 48 | "Bouvet Island", 49 | "Brazil", 50 | "British Indian Ocean Territory", 51 | "Brunei Darussalam", 52 | "Bulgaria", 53 | "Burkina Faso", 54 | "Burundi", 55 | "Cambodia", 56 | "Cameroon", 57 | "Canada", 58 | "Cape Verde", 59 | "Cayman Islands", 60 | "Central African Republic", 61 | "Chad", 62 | "Chile", 63 | "China", 64 | "Christmas Island", 65 | "Cocos (Keeling) Islands", 66 | "Colombia", 67 | "Comoros", 68 | "Congo", 69 | "Congo, The Democratic Republic of the", 70 | "Cook Islands", 71 | "Costa Rica", 72 | "Cote D'Ivoire", 73 | "Croatia", 74 | "Cuba", 75 | "Cyprus", 76 | "Czech Republic", 77 | "Denmark", 78 | "Djibouti", 79 | "Dominica", 80 | "Dominican Republic", 81 | "Ecuador", 82 | "Egypt", 83 | "El Salvador", 84 | "Equatorial Guinea", 85 | "Eritrea", 86 | "Estonia", 87 | "Ethiopia", 88 | "Falkland Islands (Malvinas)", 89 | "Faroe Islands", 90 | "Fiji", 91 | "Finland", 92 | "France", 93 | "French Guiana", 94 | "French Polynesia", 95 | "French Southern Territories", 96 | "Gabon", 97 | "Gambia", 98 | "Georgia", 99 | "Germany", 100 | "Ghana", 101 | "Gibraltar", 102 | "Greece", 103 | "Greenland", 104 | "Grenada", 105 | "Guadeloupe", 106 | "Guam", 107 | "Guatemala", 108 | "Guernsey", 109 | "Guinea", 110 | "Guinea-Bissau", 111 | "Guyana", 112 | "Haiti", 113 | "Heard Island and Mcdonald Islands", 114 | "Holy See (Vatican City State)", 115 | "Honduras", 116 | "Hong Kong", 117 | "Hungary", 118 | "Iceland", 119 | "India", 120 | "Indonesia", 121 | "Iran, Islamic Republic Of", 122 | "Iraq", 123 | "Ireland", 124 | "Isle of Man", 125 | "Israel", 126 | "Italy", 127 | "Jamaica", 128 | "Japan", 129 | "Jersey", 130 | "Jordan", 131 | "Kazakhstan", 132 | "Kenya", 133 | "Kiribati", 134 | "Korea, Democratic People'S Republic of", 135 | "Korea, Republic of", 136 | "Kuwait", 137 | "Kyrgyzstan", 138 | "Lao People'S Democratic Republic", 139 | "Latvia", 140 | "Lebanon", 141 | "Lesotho", 142 | "Liberia", 143 | "Libyan Arab Jamahiriya", 144 | "Liechtenstein", 145 | "Lithuania", 146 | "Luxembourg", 147 | "Macao", 148 | "Macedonia, The Former Yugoslav Republic of", 149 | "Madagascar", 150 | "Malawi", 151 | "Malaysia", 152 | "Maldives", 153 | "Mali", 154 | "Malta", 155 | "Marshall Islands", 156 | "Martinique", 157 | "Mauritania", 158 | "Mauritius", 159 | "Mayotte", 160 | "Mexico", 161 | "Micronesia, Federated States of", 162 | "Moldova, Republic of", 163 | "Monaco", 164 | "Mongolia", 165 | "Montserrat", 166 | "Morocco", 167 | "Mozambique", 168 | "Myanmar", 169 | "Namibia", 170 | "Nauru", 171 | "Nepal", 172 | "Netherlands", 173 | "Netherlands Antilles", 174 | "New Caledonia", 175 | "New Zealand", 176 | "Nicaragua", 177 | "Niger", 178 | "Nigeria", 179 | "Niue", 180 | "Norfolk Island", 181 | "Northern Mariana Islands", 182 | "Norway", 183 | "Oman", 184 | "Pakistan", 185 | "Palau", 186 | "Palestinian Territory, Occupied", 187 | "Panama", 188 | "Papua New Guinea", 189 | "Paraguay", 190 | "Peru", 191 | "Philippines", 192 | "Pitcairn", 193 | "Poland", 194 | "Portugal", 195 | "Puerto Rico", 196 | "Qatar", 197 | "Reunion", 198 | "Romania", 199 | "Russian Federation", 200 | "RWANDA", 201 | "Saint Helena", 202 | "Saint Kitts and Nevis", 203 | "Saint Lucia", 204 | "Saint Pierre and Miquelon", 205 | "Saint Vincent and the Grenadines", 206 | "Samoa", 207 | "San Marino", 208 | "Sao Tome and Principe", 209 | "Saudi Arabia", 210 | "Senegal", 211 | "Serbia and Montenegro", 212 | "Seychelles", 213 | "Sierra Leone", 214 | "Singapore", 215 | "Slovakia", 216 | "Slovenia", 217 | "Solomon Islands", 218 | "Somalia", 219 | "South Africa", 220 | "South Georgia and the South Sandwich Islands", 221 | "Spain", 222 | "Sri Lanka", 223 | "Sudan", 224 | "Suriname", 225 | "Svalbard and Jan Mayen", 226 | "Swaziland", 227 | "Sweden", 228 | "Switzerland", 229 | "Syrian Arab Republic", 230 | "Taiwan, Province of China", 231 | "Tajikistan", 232 | "Tanzania, United Republic of", 233 | "Thailand", 234 | "Timor-Leste", 235 | "Togo", 236 | "Tokelau", 237 | "Tonga", 238 | "Trinidad and Tobago", 239 | "Tunisia", 240 | "Turkey", 241 | "Turkmenistan", 242 | "Turks and Caicos Islands", 243 | "Tuvalu", 244 | "Uganda", 245 | "Ukraine", 246 | "United Arab Emirates", 247 | "United Kingdom", 248 | "United States", 249 | "United States Minor Outlying Islands", 250 | "Uruguay", 251 | "Uzbekistan", 252 | "Vanuatu", 253 | "Venezuela", 254 | "Viet Nam", 255 | "Virgin Islands, British", 256 | "Virgin Islands, U.S.", 257 | "Wallis and Futuna", 258 | "Western Sahara", 259 | "Yemen", 260 | "Zambia", 261 | "Zimbabwe", 262 | }, 263 | }, 264 | }, 265 | } 266 | 267 | func main() { 268 | answers := []string{} 269 | 270 | // ask the question 271 | err := survey.Ask(multiQs, &answers, survey.WithKeepFilter(true)) 272 | 273 | if err != nil { 274 | fmt.Println(err.Error()) 275 | return 276 | } 277 | // print the answers 278 | fmt.Printf("you chose: %s\n", strings.Join(answers, ", ")) 279 | } 280 | -------------------------------------------------------------------------------- /input.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/AlecAivazis/survey/v2/core" 7 | "github.com/AlecAivazis/survey/v2/terminal" 8 | ) 9 | 10 | /* 11 | Input is a regular text input that prints each character the user types on the screen 12 | and accepts the input with the enter key. Response type is a string. 13 | 14 | name := "" 15 | prompt := &survey.Input{ Message: "What is your name?" } 16 | survey.AskOne(prompt, &name) 17 | */ 18 | type Input struct { 19 | Renderer 20 | Message string 21 | Default string 22 | Help string 23 | Suggest func(toComplete string) []string 24 | answer string 25 | typedAnswer string 26 | options []core.OptionAnswer 27 | selectedIndex int 28 | showingHelp bool 29 | } 30 | 31 | // data available to the templates when processing 32 | type InputTemplateData struct { 33 | Input 34 | ShowAnswer bool 35 | ShowHelp bool 36 | Answer string 37 | PageEntries []core.OptionAnswer 38 | SelectedIndex int 39 | Config *PromptConfig 40 | } 41 | 42 | // Templates with Color formatting. See Documentation: https://github.com/mgutz/ansi#style-format 43 | var InputQuestionTemplate = ` 44 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 45 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 46 | {{- color "default+hb"}}{{ .Message }} {{color "reset"}} 47 | {{- if .ShowAnswer}} 48 | {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} 49 | {{- else if .PageEntries -}} 50 | {{- .Answer}} [Use arrows to move, enter to select, type to continue] 51 | {{- "\n"}} 52 | {{- range $ix, $choice := .PageEntries}} 53 | {{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} 54 | {{- $choice.Value}} 55 | {{- color "reset"}}{{"\n"}} 56 | {{- end}} 57 | {{- else }} 58 | {{- if or (and .Help (not .ShowHelp)) .Suggest }}{{color "cyan"}}[ 59 | {{- if and .Help (not .ShowHelp)}}{{ print .Config.HelpInput }} for help {{- if and .Suggest}}, {{end}}{{end -}} 60 | {{- if and .Suggest }}{{color "cyan"}}{{ print .Config.SuggestInput }} for suggestions{{end -}} 61 | ]{{color "reset"}} {{end}} 62 | {{- if .Default}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} 63 | {{- end}}` 64 | 65 | func (i *Input) onRune(config *PromptConfig) terminal.OnRuneFn { 66 | return terminal.OnRuneFn(func(key rune, line []rune) ([]rune, bool, error) { 67 | if i.options != nil && (key == terminal.KeyEnter || key == '\n') { 68 | return []rune(i.answer), true, nil 69 | } else if i.options != nil && key == terminal.KeyEscape { 70 | i.answer = i.typedAnswer 71 | i.options = nil 72 | } else if key == terminal.KeyArrowUp && len(i.options) > 0 { 73 | if i.selectedIndex == 0 { 74 | i.selectedIndex = len(i.options) - 1 75 | } else { 76 | i.selectedIndex-- 77 | } 78 | i.answer = i.options[i.selectedIndex].Value 79 | } else if (key == terminal.KeyArrowDown || key == terminal.KeyTab) && len(i.options) > 0 { 80 | if i.selectedIndex == len(i.options)-1 { 81 | i.selectedIndex = 0 82 | } else { 83 | i.selectedIndex++ 84 | } 85 | i.answer = i.options[i.selectedIndex].Value 86 | } else if key == terminal.KeyTab && i.Suggest != nil { 87 | i.answer = string(line) 88 | i.typedAnswer = i.answer 89 | options := i.Suggest(i.answer) 90 | i.selectedIndex = 0 91 | if len(options) == 0 { 92 | return line, false, nil 93 | } 94 | 95 | i.answer = options[0] 96 | if len(options) == 1 { 97 | i.typedAnswer = i.answer 98 | i.options = nil 99 | } else { 100 | i.options = core.OptionAnswerList(options) 101 | } 102 | } else { 103 | if i.options == nil { 104 | return line, false, nil 105 | } 106 | 107 | if key >= terminal.KeySpace { 108 | i.answer += string(key) 109 | } 110 | i.typedAnswer = i.answer 111 | 112 | i.options = nil 113 | } 114 | 115 | pageSize := config.PageSize 116 | opts, idx := paginate(pageSize, i.options, i.selectedIndex) 117 | err := i.Render( 118 | InputQuestionTemplate, 119 | InputTemplateData{ 120 | Input: *i, 121 | Answer: i.answer, 122 | ShowHelp: i.showingHelp, 123 | SelectedIndex: idx, 124 | PageEntries: opts, 125 | Config: config, 126 | }, 127 | ) 128 | 129 | if err == nil { 130 | err = errReadLineAgain 131 | } 132 | 133 | return []rune(i.typedAnswer), true, err 134 | }) 135 | } 136 | 137 | var errReadLineAgain = errors.New("read line again") 138 | 139 | func (i *Input) Prompt(config *PromptConfig) (interface{}, error) { 140 | // render the template 141 | err := i.Render( 142 | InputQuestionTemplate, 143 | InputTemplateData{ 144 | Input: *i, 145 | Config: config, 146 | ShowHelp: i.showingHelp, 147 | }, 148 | ) 149 | if err != nil { 150 | return "", err 151 | } 152 | 153 | // start reading runes from the standard in 154 | rr := i.NewRuneReader() 155 | _ = rr.SetTermMode() 156 | defer func() { 157 | _ = rr.RestoreTermMode() 158 | }() 159 | cursor := i.NewCursor() 160 | if !config.ShowCursor { 161 | cursor.Hide() // hide the cursor 162 | defer cursor.Show() // show the cursor when we're done 163 | } 164 | 165 | var line []rune 166 | 167 | for { 168 | if i.options != nil { 169 | line = []rune{} 170 | } 171 | 172 | line, err = rr.ReadLineWithDefault(0, line, i.onRune(config)) 173 | if err == errReadLineAgain { 174 | continue 175 | } 176 | 177 | if err != nil { 178 | return "", err 179 | } 180 | 181 | break 182 | } 183 | 184 | i.answer = string(line) 185 | // readline print an empty line, go up before we render the follow up 186 | cursor.Up(1) 187 | 188 | // if we ran into the help string 189 | if i.answer == config.HelpInput && i.Help != "" { 190 | // show the help and prompt again 191 | i.showingHelp = true 192 | return i.Prompt(config) 193 | } 194 | 195 | // if the line is empty 196 | if len(i.answer) == 0 { 197 | // use the default value 198 | return i.Default, err 199 | } 200 | 201 | lineStr := i.answer 202 | 203 | i.AppendRenderedText(lineStr) 204 | 205 | // we're done 206 | return lineStr, err 207 | } 208 | 209 | func (i *Input) Cleanup(config *PromptConfig, val interface{}) error { 210 | return i.Render( 211 | InputQuestionTemplate, 212 | InputTemplateData{ 213 | Input: *i, 214 | ShowAnswer: true, 215 | Config: config, 216 | Answer: val.(string), 217 | }, 218 | ) 219 | } 220 | -------------------------------------------------------------------------------- /editor_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "testing" 10 | "time" 11 | 12 | "github.com/AlecAivazis/survey/v2/core" 13 | "github.com/AlecAivazis/survey/v2/terminal" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func init() { 18 | // disable color output for all prompts to simplify testing 19 | core.DisableColor = true 20 | } 21 | 22 | func TestEditorRender(t *testing.T) { 23 | tests := []struct { 24 | title string 25 | prompt Editor 26 | data EditorTemplateData 27 | expected string 28 | }{ 29 | { 30 | "Test Editor question output without default", 31 | Editor{Message: "What is your favorite month:"}, 32 | EditorTemplateData{}, 33 | fmt.Sprintf("%s What is your favorite month: [Enter to launch editor] ", defaultIcons().Question.Text), 34 | }, 35 | { 36 | "Test Editor question output with default", 37 | Editor{Message: "What is your favorite month:", Default: "April"}, 38 | EditorTemplateData{}, 39 | fmt.Sprintf("%s What is your favorite month: (April) [Enter to launch editor] ", defaultIcons().Question.Text), 40 | }, 41 | { 42 | "Test Editor question output with HideDefault", 43 | Editor{Message: "What is your favorite month:", Default: "April", HideDefault: true}, 44 | EditorTemplateData{}, 45 | fmt.Sprintf("%s What is your favorite month: [Enter to launch editor] ", defaultIcons().Question.Text), 46 | }, 47 | { 48 | "Test Editor answer output", 49 | Editor{Message: "What is your favorite month:"}, 50 | EditorTemplateData{Answer: "October", ShowAnswer: true}, 51 | fmt.Sprintf("%s What is your favorite month: October\n", defaultIcons().Question.Text), 52 | }, 53 | { 54 | "Test Editor question output without default but with help hidden", 55 | Editor{Message: "What is your favorite month:", Help: "This is helpful"}, 56 | EditorTemplateData{}, 57 | fmt.Sprintf("%s What is your favorite month: [%s for help] [Enter to launch editor] ", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput)), 58 | }, 59 | { 60 | "Test Editor question output with default and with help hidden", 61 | Editor{Message: "What is your favorite month:", Default: "April", Help: "This is helpful"}, 62 | EditorTemplateData{}, 63 | fmt.Sprintf("%s What is your favorite month: [%s for help] (April) [Enter to launch editor] ", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput)), 64 | }, 65 | { 66 | "Test Editor question output without default but with help shown", 67 | Editor{Message: "What is your favorite month:", Help: "This is helpful"}, 68 | EditorTemplateData{ShowHelp: true}, 69 | fmt.Sprintf("%s This is helpful\n%s What is your favorite month: [Enter to launch editor] ", defaultIcons().Help.Text, defaultIcons().Question.Text), 70 | }, 71 | { 72 | "Test Editor question output with default and with help shown", 73 | Editor{Message: "What is your favorite month:", Default: "April", Help: "This is helpful"}, 74 | EditorTemplateData{ShowHelp: true}, 75 | fmt.Sprintf("%s This is helpful\n%s What is your favorite month: (April) [Enter to launch editor] ", defaultIcons().Help.Text, defaultIcons().Question.Text), 76 | }, 77 | } 78 | 79 | for _, test := range tests { 80 | t.Run(test.title, func(t *testing.T) { 81 | r, w, err := os.Pipe() 82 | assert.NoError(t, err) 83 | 84 | test.prompt.WithStdio(terminal.Stdio{Out: w}) 85 | test.data.Editor = test.prompt 86 | 87 | // set the icon set 88 | test.data.Config = defaultPromptConfig() 89 | 90 | err = test.prompt.Render( 91 | EditorQuestionTemplate, 92 | test.data, 93 | ) 94 | assert.NoError(t, err) 95 | 96 | assert.NoError(t, w.Close()) 97 | var buf bytes.Buffer 98 | _, err = io.Copy(&buf, r) 99 | assert.NoError(t, err) 100 | 101 | assert.Contains(t, buf.String(), test.expected) 102 | }) 103 | } 104 | } 105 | 106 | func TestEditorPrompt(t *testing.T) { 107 | if _, err := exec.LookPath("vi"); err != nil { 108 | t.Skip("warning: vi not found in PATH") 109 | } 110 | 111 | tests := []PromptTest{ 112 | { 113 | "Test Editor prompt interaction", 114 | &Editor{ 115 | Editor: "vi", 116 | Message: "Edit git commit message", 117 | }, 118 | func(c expectConsole) { 119 | c.ExpectString("Edit git commit message [Enter to launch editor]") 120 | c.SendLine("") 121 | time.Sleep(time.Millisecond) 122 | c.Send("ccAdd editor prompt tests\x1b") 123 | c.SendLine(":wq!") 124 | c.ExpectEOF() 125 | }, 126 | "Add editor prompt tests\n", 127 | }, 128 | { 129 | "Test Editor prompt interaction with default", 130 | &Editor{ 131 | Editor: "vi", 132 | Message: "Edit git commit message", 133 | Default: "No comment", 134 | }, 135 | func(c expectConsole) { 136 | c.ExpectString("Edit git commit message (No comment) [Enter to launch editor]") 137 | c.SendLine("") 138 | time.Sleep(time.Millisecond) 139 | c.SendLine(":q!") 140 | c.ExpectEOF() 141 | }, 142 | "No comment", 143 | }, 144 | { 145 | "Test Editor prompt interaction overriding default", 146 | &Editor{ 147 | Editor: "vi", 148 | Message: "Edit git commit message", 149 | Default: "No comment", 150 | }, 151 | func(c expectConsole) { 152 | c.ExpectString("Edit git commit message (No comment) [Enter to launch editor]") 153 | c.SendLine("") 154 | time.Sleep(time.Millisecond) 155 | c.Send("ccAdd editor prompt tests\x1b") 156 | c.SendLine(":wq!") 157 | c.ExpectEOF() 158 | }, 159 | "Add editor prompt tests\n", 160 | }, 161 | { 162 | "Test Editor prompt interaction hiding default", 163 | &Editor{ 164 | Editor: "vi", 165 | Message: "Edit git commit message", 166 | Default: "No comment", 167 | HideDefault: true, 168 | }, 169 | func(c expectConsole) { 170 | c.ExpectString("Edit git commit message [Enter to launch editor]") 171 | c.SendLine("") 172 | time.Sleep(time.Millisecond) 173 | c.SendLine(":q!") 174 | c.ExpectEOF() 175 | }, 176 | "No comment", 177 | }, 178 | { 179 | "Test Editor prompt interaction and prompt for help", 180 | &Editor{ 181 | Editor: "vi", 182 | Message: "Edit git commit message", 183 | Help: "Describe your git commit", 184 | }, 185 | func(c expectConsole) { 186 | c.ExpectString( 187 | fmt.Sprintf( 188 | "Edit git commit message [%s for help] [Enter to launch editor]", 189 | string(defaultPromptConfig().HelpInput), 190 | ), 191 | ) 192 | c.SendLine("?") 193 | c.ExpectString("Describe your git commit") 194 | c.SendLine("") 195 | time.Sleep(time.Millisecond) 196 | c.Send("ccAdd editor prompt tests\x1b") 197 | c.SendLine(":wq!") 198 | c.ExpectEOF() 199 | }, 200 | "Add editor prompt tests\n", 201 | }, 202 | { 203 | "Test Editor prompt interaction with default and append default", 204 | &Editor{ 205 | Editor: "vi", 206 | Message: "Edit git commit message", 207 | Default: "No comment", 208 | AppendDefault: true, 209 | }, 210 | func(c expectConsole) { 211 | c.ExpectString("Edit git commit message (No comment) [Enter to launch editor]") 212 | c.SendLine("") 213 | c.ExpectString("No comment") 214 | c.SendLine("dd") 215 | c.SendLine(":wq!") 216 | c.ExpectEOF() 217 | }, 218 | "", 219 | }, 220 | { 221 | "Test Editor prompt interaction with editor args", 222 | &Editor{ 223 | Editor: "vi --", 224 | Message: "Edit git commit message", 225 | }, 226 | func(c expectConsole) { 227 | c.ExpectString("Edit git commit message [Enter to launch editor]") 228 | c.SendLine("") 229 | time.Sleep(time.Millisecond) 230 | c.Send("ccAdd editor prompt tests\x1b") 231 | c.SendLine(":wq!") 232 | c.ExpectEOF() 233 | }, 234 | "Add editor prompt tests\n", 235 | }, 236 | } 237 | 238 | for _, test := range tests { 239 | t.Run(test.name, func(t *testing.T) { 240 | RunPromptTest(t, test) 241 | }) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /select.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/AlecAivazis/survey/v2/core" 8 | "github.com/AlecAivazis/survey/v2/terminal" 9 | ) 10 | 11 | /* 12 | Select is a prompt that presents a list of various options to the user 13 | for them to select using the arrow keys and enter. Response type is a string. 14 | 15 | color := "" 16 | prompt := &survey.Select{ 17 | Message: "Choose a color:", 18 | Options: []string{"red", "blue", "green"}, 19 | } 20 | survey.AskOne(prompt, &color) 21 | */ 22 | type Select struct { 23 | Renderer 24 | Message string 25 | Options []string 26 | Default interface{} 27 | Help string 28 | PageSize int 29 | VimMode bool 30 | FilterMessage string 31 | Filter func(filter string, value string, index int) bool 32 | Description func(value string, index int) string 33 | filter string 34 | selectedIndex int 35 | showingHelp bool 36 | } 37 | 38 | // SelectTemplateData is the data available to the templates when processing 39 | type SelectTemplateData struct { 40 | Select 41 | PageEntries []core.OptionAnswer 42 | SelectedIndex int 43 | Answer string 44 | ShowAnswer bool 45 | ShowHelp bool 46 | Description func(value string, index int) string 47 | Config *PromptConfig 48 | 49 | // These fields are used when rendering an individual option 50 | CurrentOpt core.OptionAnswer 51 | CurrentIndex int 52 | } 53 | 54 | // IterateOption sets CurrentOpt and CurrentIndex appropriately so a select option can be rendered individually 55 | func (s SelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} { 56 | copy := s 57 | copy.CurrentIndex = ix 58 | copy.CurrentOpt = opt 59 | return copy 60 | } 61 | 62 | func (s SelectTemplateData) GetDescription(opt core.OptionAnswer) string { 63 | if s.Description == nil { 64 | return "" 65 | } 66 | return s.Description(opt.Value, opt.Index) 67 | } 68 | 69 | var SelectQuestionTemplate = ` 70 | {{- define "option"}} 71 | {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} 72 | {{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{end}} 73 | {{- color "reset"}} 74 | {{end}} 75 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 76 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 77 | {{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} 78 | {{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} 79 | {{- else}} 80 | {{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}} 81 | {{- "\n"}} 82 | {{- range $ix, $option := .PageEntries}} 83 | {{- template "option" $.IterateOption $ix $option}} 84 | {{- end}} 85 | {{- end}}` 86 | 87 | // OnChange is called on every keypress. 88 | func (s *Select) OnChange(key rune, config *PromptConfig) bool { 89 | options := s.filterOptions(config) 90 | oldFilter := s.filter 91 | 92 | // if the user pressed the enter key and the index is a valid option 93 | if key == terminal.KeyEnter || key == '\n' { 94 | // if the selected index is a valid option 95 | if len(options) > 0 && s.selectedIndex < len(options) { 96 | 97 | // we're done (stop prompting the user) 98 | return true 99 | } 100 | 101 | // we're not done (keep prompting) 102 | return false 103 | 104 | // if the user pressed the up arrow or 'k' to emulate vim 105 | } else if (key == terminal.KeyArrowUp || (s.VimMode && key == 'k')) && len(options) > 0 { 106 | // if we are at the top of the list 107 | if s.selectedIndex == 0 { 108 | // start from the button 109 | s.selectedIndex = len(options) - 1 110 | } else { 111 | // otherwise we are not at the top of the list so decrement the selected index 112 | s.selectedIndex-- 113 | } 114 | 115 | // if the user pressed down or 'j' to emulate vim 116 | } else if (key == terminal.KeyTab || key == terminal.KeyArrowDown || (s.VimMode && key == 'j')) && len(options) > 0 { 117 | // if we are at the bottom of the list 118 | if s.selectedIndex == len(options)-1 { 119 | // start from the top 120 | s.selectedIndex = 0 121 | } else { 122 | // increment the selected index 123 | s.selectedIndex++ 124 | } 125 | // only show the help message if we have one 126 | } else if string(key) == config.HelpInput && s.Help != "" { 127 | s.showingHelp = true 128 | // if the user wants to toggle vim mode on/off 129 | } else if key == terminal.KeyEscape { 130 | s.VimMode = !s.VimMode 131 | // if the user hits any of the keys that clear the filter 132 | } else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine { 133 | s.filter = "" 134 | // if the user is deleting a character in the filter 135 | } else if key == terminal.KeyDelete || key == terminal.KeyBackspace { 136 | // if there is content in the filter to delete 137 | if s.filter != "" { 138 | runeFilter := []rune(s.filter) 139 | // subtract a line from the current filter 140 | s.filter = string(runeFilter[0 : len(runeFilter)-1]) 141 | // we removed the last value in the filter 142 | } 143 | } else if key >= terminal.KeySpace { 144 | s.filter += string(key) 145 | // make sure vim mode is disabled 146 | s.VimMode = false 147 | } 148 | 149 | s.FilterMessage = "" 150 | if s.filter != "" { 151 | s.FilterMessage = " " + s.filter 152 | } 153 | if oldFilter != s.filter { 154 | // filter changed 155 | options = s.filterOptions(config) 156 | if len(options) > 0 && len(options) <= s.selectedIndex { 157 | s.selectedIndex = len(options) - 1 158 | } 159 | } 160 | 161 | // figure out the options and index to render 162 | // figure out the page size 163 | pageSize := s.PageSize 164 | // if we dont have a specific one 165 | if pageSize == 0 { 166 | // grab the global value 167 | pageSize = config.PageSize 168 | } 169 | 170 | // TODO if we have started filtering and were looking at the end of a list 171 | // and we have modified the filter then we should move the page back! 172 | opts, idx := paginate(pageSize, options, s.selectedIndex) 173 | 174 | tmplData := SelectTemplateData{ 175 | Select: *s, 176 | SelectedIndex: idx, 177 | ShowHelp: s.showingHelp, 178 | Description: s.Description, 179 | PageEntries: opts, 180 | Config: config, 181 | } 182 | 183 | // render the options 184 | _ = s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx) 185 | 186 | // keep prompting 187 | return false 188 | } 189 | 190 | func (s *Select) filterOptions(config *PromptConfig) []core.OptionAnswer { 191 | // the filtered list 192 | answers := []core.OptionAnswer{} 193 | 194 | // if there is no filter applied 195 | if s.filter == "" { 196 | return core.OptionAnswerList(s.Options) 197 | } 198 | 199 | // the filter to apply 200 | filter := s.Filter 201 | if filter == nil { 202 | filter = config.Filter 203 | } 204 | 205 | for i, opt := range s.Options { 206 | // i the filter says to include the option 207 | if filter(s.filter, opt, i) { 208 | answers = append(answers, core.OptionAnswer{ 209 | Index: i, 210 | Value: opt, 211 | }) 212 | } 213 | } 214 | 215 | // return the list of answers 216 | return answers 217 | } 218 | 219 | func (s *Select) Prompt(config *PromptConfig) (interface{}, error) { 220 | // if there are no options to render 221 | if len(s.Options) == 0 { 222 | // we failed 223 | return "", errors.New("please provide options to select from") 224 | } 225 | 226 | s.selectedIndex = 0 227 | if s.Default != nil { 228 | switch defaultValue := s.Default.(type) { 229 | case string: 230 | var found bool 231 | for i, opt := range s.Options { 232 | if opt == defaultValue { 233 | s.selectedIndex = i 234 | found = true 235 | } 236 | } 237 | if !found { 238 | return "", fmt.Errorf("default value %q not found in options", defaultValue) 239 | } 240 | case int: 241 | if defaultValue >= len(s.Options) { 242 | return "", fmt.Errorf("default index %d exceeds the number of options", defaultValue) 243 | } 244 | s.selectedIndex = defaultValue 245 | default: 246 | return "", errors.New("default value of select must be an int or string") 247 | } 248 | } 249 | 250 | // figure out the page size 251 | pageSize := s.PageSize 252 | // if we dont have a specific one 253 | if pageSize == 0 { 254 | // grab the global value 255 | pageSize = config.PageSize 256 | } 257 | 258 | // figure out the options and index to render 259 | opts, idx := paginate(pageSize, core.OptionAnswerList(s.Options), s.selectedIndex) 260 | 261 | cursor := s.NewCursor() 262 | cursor.Save() // for proper cursor placement during selection 263 | cursor.Hide() // hide the cursor 264 | defer cursor.Show() // show the cursor when we're done 265 | defer cursor.Restore() // clear any accessibility offsetting on exit 266 | 267 | tmplData := SelectTemplateData{ 268 | Select: *s, 269 | SelectedIndex: idx, 270 | Description: s.Description, 271 | ShowHelp: s.showingHelp, 272 | PageEntries: opts, 273 | Config: config, 274 | } 275 | 276 | // ask the question 277 | err := s.RenderWithCursorOffset(SelectQuestionTemplate, tmplData, opts, idx) 278 | if err != nil { 279 | return "", err 280 | } 281 | 282 | rr := s.NewRuneReader() 283 | _ = rr.SetTermMode() 284 | defer func() { 285 | _ = rr.RestoreTermMode() 286 | }() 287 | 288 | // start waiting for input 289 | for { 290 | r, _, err := rr.ReadRune() 291 | if err != nil { 292 | return "", err 293 | } 294 | if r == terminal.KeyInterrupt { 295 | return "", terminal.InterruptErr 296 | } 297 | if r == terminal.KeyEndTransmission { 298 | break 299 | } 300 | if s.OnChange(r, config) { 301 | break 302 | } 303 | } 304 | 305 | options := s.filterOptions(config) 306 | s.filter = "" 307 | s.FilterMessage = "" 308 | 309 | if s.selectedIndex < len(options) { 310 | return options[s.selectedIndex], err 311 | } 312 | 313 | return options[0], err 314 | } 315 | 316 | func (s *Select) Cleanup(config *PromptConfig, val interface{}) error { 317 | cursor := s.NewCursor() 318 | cursor.Restore() 319 | return s.Render( 320 | SelectQuestionTemplate, 321 | SelectTemplateData{ 322 | Select: *s, 323 | Answer: val.(core.OptionAnswer).Value, 324 | ShowAnswer: true, 325 | Description: s.Description, 326 | Config: config, 327 | }, 328 | ) 329 | } 330 | -------------------------------------------------------------------------------- /select_test.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/AlecAivazis/survey/v2/core" 14 | "github.com/AlecAivazis/survey/v2/terminal" 15 | ) 16 | 17 | func init() { 18 | // disable color output for all prompts to simplify testing 19 | core.DisableColor = true 20 | } 21 | 22 | func TestSelectRender(t *testing.T) { 23 | 24 | prompt := Select{ 25 | Message: "Pick your word:", 26 | Options: []string{"foo", "bar", "baz", "buz"}, 27 | Default: "baz", 28 | } 29 | 30 | helpfulPrompt := prompt 31 | helpfulPrompt.Help = "This is helpful" 32 | 33 | tests := []struct { 34 | title string 35 | prompt Select 36 | data SelectTemplateData 37 | expected string 38 | }{ 39 | { 40 | "Test Select question output", 41 | prompt, 42 | SelectTemplateData{SelectedIndex: 2, PageEntries: core.OptionAnswerList(prompt.Options)}, 43 | strings.Join( 44 | []string{ 45 | fmt.Sprintf("%s Pick your word: [Use arrows to move, type to filter]", defaultIcons().Question.Text), 46 | " foo", 47 | " bar", 48 | fmt.Sprintf("%s baz", defaultIcons().SelectFocus.Text), 49 | " buz\n", 50 | }, 51 | "\n", 52 | ), 53 | }, 54 | { 55 | "Test Select answer output", 56 | prompt, 57 | SelectTemplateData{Answer: "buz", ShowAnswer: true, PageEntries: core.OptionAnswerList(prompt.Options)}, 58 | fmt.Sprintf("%s Pick your word: buz\n", defaultIcons().Question.Text), 59 | }, 60 | { 61 | "Test Select question output with help hidden", 62 | helpfulPrompt, 63 | SelectTemplateData{SelectedIndex: 2, PageEntries: core.OptionAnswerList(prompt.Options)}, 64 | strings.Join( 65 | []string{ 66 | fmt.Sprintf("%s Pick your word: [Use arrows to move, type to filter, %s for more help]", defaultIcons().Question.Text, string(defaultPromptConfig().HelpInput)), 67 | " foo", 68 | " bar", 69 | fmt.Sprintf("%s baz", defaultIcons().SelectFocus.Text), 70 | " buz\n", 71 | }, 72 | "\n", 73 | ), 74 | }, 75 | { 76 | "Test Select question output with help shown", 77 | helpfulPrompt, 78 | SelectTemplateData{SelectedIndex: 2, ShowHelp: true, PageEntries: core.OptionAnswerList(prompt.Options)}, 79 | strings.Join( 80 | []string{ 81 | fmt.Sprintf("%s This is helpful", defaultIcons().Help.Text), 82 | fmt.Sprintf("%s Pick your word: [Use arrows to move, type to filter]", defaultIcons().Question.Text), 83 | " foo", 84 | " bar", 85 | fmt.Sprintf("%s baz", defaultIcons().SelectFocus.Text), 86 | " buz\n", 87 | }, 88 | "\n", 89 | ), 90 | }, 91 | } 92 | 93 | for _, test := range tests { 94 | t.Run(test.title, func(t *testing.T) { 95 | r, w, err := os.Pipe() 96 | assert.NoError(t, err) 97 | 98 | test.prompt.WithStdio(terminal.Stdio{Out: w}) 99 | test.data.Select = test.prompt 100 | 101 | // set the icon set 102 | test.data.Config = defaultPromptConfig() 103 | 104 | err = test.prompt.Render( 105 | SelectQuestionTemplate, 106 | test.data, 107 | ) 108 | assert.NoError(t, err) 109 | 110 | assert.NoError(t, w.Close()) 111 | var buf bytes.Buffer 112 | _, err = io.Copy(&buf, r) 113 | assert.NoError(t, err) 114 | 115 | assert.Contains(t, buf.String(), test.expected) 116 | }) 117 | } 118 | } 119 | 120 | func TestSelectPrompt(t *testing.T) { 121 | tests := []PromptTest{ 122 | { 123 | "basic interaction: blue", 124 | &Select{ 125 | Message: "Choose a color:", 126 | Options: []string{"red", "blue", "green"}, 127 | }, 128 | func(c expectConsole) { 129 | c.ExpectString("Choose a color:") 130 | // Select blue. 131 | c.SendLine(string(terminal.KeyArrowDown)) 132 | c.ExpectEOF() 133 | }, 134 | core.OptionAnswer{Index: 1, Value: "blue"}, 135 | }, 136 | { 137 | "basic interaction: green", 138 | &Select{ 139 | Message: "Choose a color:", 140 | Options: []string{"red", "blue", "green"}, 141 | }, 142 | func(c expectConsole) { 143 | c.ExpectString("Choose a color:") 144 | // Select blue. 145 | c.Send(string(terminal.KeyArrowDown)) 146 | // Select green. 147 | c.SendLine(string(terminal.KeyTab)) 148 | c.ExpectEOF() 149 | }, 150 | core.OptionAnswer{Index: 2, Value: "green"}, 151 | }, 152 | { 153 | "default value", 154 | &Select{ 155 | Message: "Choose a color:", 156 | Options: []string{"red", "blue", "green"}, 157 | Default: "green", 158 | }, 159 | func(c expectConsole) { 160 | c.ExpectString("Choose a color:") 161 | // Select green. 162 | c.SendLine("") 163 | c.ExpectEOF() 164 | }, 165 | core.OptionAnswer{Index: 2, Value: "green"}, 166 | }, 167 | { 168 | "default index", 169 | &Select{ 170 | Message: "Choose a color:", 171 | Options: []string{"red", "blue", "green"}, 172 | Default: 2, 173 | }, 174 | func(c expectConsole) { 175 | c.ExpectString("Choose a color:") 176 | // Select green. 177 | c.SendLine("") 178 | c.ExpectEOF() 179 | }, 180 | core.OptionAnswer{Index: 2, Value: "green"}, 181 | }, 182 | { 183 | "overriding default", 184 | &Select{ 185 | Message: "Choose a color:", 186 | Options: []string{"red", "blue", "green"}, 187 | Default: "blue", 188 | }, 189 | func(c expectConsole) { 190 | c.ExpectString("Choose a color:") 191 | // Select red. 192 | c.SendLine(string(terminal.KeyArrowUp)) 193 | c.ExpectEOF() 194 | }, 195 | core.OptionAnswer{Index: 0, Value: "red"}, 196 | }, 197 | { 198 | "SKIP: prompt for help", 199 | &Select{ 200 | Message: "Choose a color:", 201 | Options: []string{"red", "blue", "green"}, 202 | Help: "My favourite color is red", 203 | }, 204 | func(c expectConsole) { 205 | c.ExpectString("Choose a color:") 206 | c.SendLine("?") 207 | c.ExpectString("My favourite color is red") 208 | // Select red. 209 | c.SendLine("") 210 | c.ExpectEOF() 211 | }, 212 | core.OptionAnswer{Index: 0, Value: "red"}, 213 | }, 214 | { 215 | "PageSize", 216 | &Select{ 217 | Message: "Choose a color:", 218 | Options: []string{"red", "blue", "green"}, 219 | PageSize: 1, 220 | }, 221 | func(c expectConsole) { 222 | c.ExpectString("Choose a color:") 223 | // Select green. 224 | c.SendLine(string(terminal.KeyArrowUp)) 225 | c.ExpectEOF() 226 | }, 227 | core.OptionAnswer{Index: 2, Value: "green"}, 228 | }, 229 | { 230 | "vim mode", 231 | &Select{ 232 | Message: "Choose a color:", 233 | Options: []string{"red", "blue", "green"}, 234 | VimMode: true, 235 | }, 236 | func(c expectConsole) { 237 | c.ExpectString("Choose a color:") 238 | // Select blue. 239 | c.SendLine("j") 240 | c.ExpectEOF() 241 | }, 242 | core.OptionAnswer{Index: 1, Value: "blue"}, 243 | }, 244 | { 245 | "filter", 246 | &Select{ 247 | Message: "Choose a color:", 248 | Options: []string{"red", "blue", "green"}, 249 | }, 250 | func(c expectConsole) { 251 | c.ExpectString("Choose a color:") 252 | // Filter down to red and green. 253 | c.Send("re") 254 | // Select green. 255 | c.SendLine(string(terminal.KeyArrowDown)) 256 | c.ExpectEOF() 257 | }, 258 | core.OptionAnswer{Index: 2, Value: "green"}, 259 | }, 260 | { 261 | "filter is case-insensitive", 262 | &Select{ 263 | Message: "Choose a color:", 264 | Options: []string{"red", "blue", "green"}, 265 | }, 266 | func(c expectConsole) { 267 | c.ExpectString("Choose a color:") 268 | // Filter down to red and green. 269 | c.Send("RE") 270 | // Select green. 271 | c.SendLine(string(terminal.KeyArrowDown)) 272 | c.ExpectEOF() 273 | }, 274 | core.OptionAnswer{Index: 2, Value: "green"}, 275 | }, 276 | { 277 | "Can select the first result in a filtered list if there is a default", 278 | &Select{ 279 | Message: "Choose a color:", 280 | Options: []string{"red", "blue", "green"}, 281 | Default: "blue", 282 | }, 283 | func(c expectConsole) { 284 | c.ExpectString("Choose a color:") 285 | // Make sure only red is showing 286 | c.SendLine("red") 287 | c.ExpectEOF() 288 | }, 289 | core.OptionAnswer{Index: 0, Value: "red"}, 290 | }, 291 | { 292 | "custom filter", 293 | &Select{ 294 | Message: "Choose a color:", 295 | Options: []string{"red", "blue", "green"}, 296 | Filter: func(filter string, optValue string, optIndex int) (filtered bool) { 297 | return len(optValue) >= 5 298 | }, 299 | }, 300 | func(c expectConsole) { 301 | c.ExpectString("Choose a color:") 302 | // Filter down to only green since custom filter only keeps options that are longer than 5 runes 303 | c.SendLine("re") 304 | c.ExpectEOF() 305 | }, 306 | core.OptionAnswer{Index: 2, Value: "green"}, 307 | }, 308 | { 309 | "answers filtered out", 310 | &Select{ 311 | Message: "Choose a color:", 312 | Options: []string{"red", "blue", "green"}, 313 | }, 314 | func(c expectConsole) { 315 | c.ExpectString("Choose a color:") 316 | // filter away everything 317 | c.SendLine("z") 318 | // send enter (should get ignored since there are no answers) 319 | c.SendLine(string(terminal.KeyEnter)) 320 | 321 | // remove the filter we just applied 322 | c.SendLine(string(terminal.KeyBackspace)) 323 | 324 | // press enter 325 | c.SendLine(string(terminal.KeyEnter)) 326 | }, 327 | core.OptionAnswer{Index: 0, Value: "red"}, 328 | }, 329 | { 330 | "delete filter word", 331 | &Select{ 332 | Message: "Choose a color:", 333 | Options: []string{"red", "blue", "black"}, 334 | }, 335 | func(c expectConsole) { 336 | c.ExpectString("Choose a color:") 337 | // Filter down to blue. 338 | c.Send("blu") 339 | // Filter down to blue and black. 340 | c.Send(string(terminal.KeyDelete)) 341 | // Select black. 342 | c.SendLine(string(terminal.KeyArrowDown)) 343 | c.ExpectEOF() 344 | }, 345 | core.OptionAnswer{Index: 2, Value: "black"}, 346 | }, 347 | { 348 | "delete filter word in rune", 349 | &Select{ 350 | Message: "今天中午吃什么?", 351 | Options: []string{"青椒牛肉丝", "小炒肉", "小煎鸡"}, 352 | }, 353 | func(c expectConsole) { 354 | c.ExpectString("今天中午吃什么?") 355 | // Filter down to 小炒肉. 356 | c.Send("小炒") 357 | // Filter down to 小炒肉 and 小煎鸡. 358 | c.Send(string(terminal.KeyBackspace)) 359 | // Select 小煎鸡. 360 | c.SendLine(string(terminal.KeyArrowDown)) 361 | c.ExpectEOF() 362 | }, 363 | core.OptionAnswer{Index: 2, Value: "小煎鸡"}, 364 | }, 365 | } 366 | 367 | for _, test := range tests { 368 | testName := strings.TrimPrefix(test.name, "SKIP: ") 369 | t.Run(testName, func(t *testing.T) { 370 | if testName != test.name { 371 | t.Skipf("warning: flakey test %q", testName) 372 | } 373 | RunPromptTest(t, test) 374 | }) 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /core/write.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // the tag used to denote the name of the question 13 | const tagName = "survey" 14 | 15 | // Settable allow for configuration when assigning answers 16 | type Settable interface { 17 | WriteAnswer(field string, value interface{}) error 18 | } 19 | 20 | // OptionAnswer is the return type of Selects/MultiSelects that lets the appropriate information 21 | // get copied to the user's struct 22 | type OptionAnswer struct { 23 | Value string 24 | Index int 25 | } 26 | 27 | type reflectField struct { 28 | value reflect.Value 29 | fieldType reflect.StructField 30 | } 31 | 32 | func OptionAnswerList(incoming []string) []OptionAnswer { 33 | list := []OptionAnswer{} 34 | for i, opt := range incoming { 35 | list = append(list, OptionAnswer{Value: opt, Index: i}) 36 | } 37 | return list 38 | } 39 | 40 | func WriteAnswer(t interface{}, name string, v interface{}) (err error) { 41 | // if the field is a custom type 42 | if s, ok := t.(Settable); ok { 43 | // use the interface method 44 | return s.WriteAnswer(name, v) 45 | } 46 | 47 | // the target to write to 48 | target := reflect.ValueOf(t) 49 | // the value to write from 50 | value := reflect.ValueOf(v) 51 | 52 | // make sure we are writing to a pointer 53 | if target.Kind() != reflect.Ptr { 54 | return errors.New("you must pass a pointer as the target of a Write operation") 55 | } 56 | // the object "inside" of the target pointer 57 | elem := target.Elem() 58 | 59 | // handle the special types 60 | switch elem.Kind() { 61 | // if we are writing to a struct 62 | case reflect.Struct: 63 | // if we are writing to an option answer than we want to treat 64 | // it like a single thing and not a place to deposit answers 65 | if elem.Type().Name() == "OptionAnswer" { 66 | // copy the value over to the normal struct 67 | return copy(elem, value) 68 | } 69 | 70 | // get the name of the field that matches the string we were given 71 | field, _, err := findField(elem, name) 72 | // if something went wrong 73 | if err != nil { 74 | // bubble up 75 | return err 76 | } 77 | // handle references to the Settable interface aswell 78 | if s, ok := field.Interface().(Settable); ok { 79 | // use the interface method 80 | return s.WriteAnswer(name, v) 81 | } 82 | if field.CanAddr() { 83 | if s, ok := field.Addr().Interface().(Settable); ok { 84 | // use the interface method 85 | return s.WriteAnswer(name, v) 86 | } 87 | } 88 | 89 | // copy the value over to the normal struct 90 | return copy(field, value) 91 | case reflect.Map: 92 | mapType := reflect.TypeOf(t).Elem() 93 | if mapType.Key().Kind() != reflect.String { 94 | return errors.New("answer maps key must be of type string") 95 | } 96 | 97 | // copy only string value/index value to map if, 98 | // map is not of type interface and is 'OptionAnswer' 99 | if value.Type().Name() == "OptionAnswer" { 100 | if kval := mapType.Elem().Kind(); kval == reflect.String { 101 | mt := *t.(*map[string]string) 102 | mt[name] = value.FieldByName("Value").String() 103 | return nil 104 | } else if kval == reflect.Int { 105 | mt := *t.(*map[string]int) 106 | mt[name] = int(value.FieldByName("Index").Int()) 107 | return nil 108 | } 109 | } 110 | 111 | if mapType.Elem().Kind() != reflect.Interface { 112 | return errors.New("answer maps must be of type map[string]interface") 113 | } 114 | mt := *t.(*map[string]interface{}) 115 | mt[name] = value.Interface() 116 | return nil 117 | } 118 | // otherwise just copy the value to the target 119 | return copy(elem, value) 120 | } 121 | 122 | type errFieldNotMatch struct { 123 | questionName string 124 | } 125 | 126 | func (err errFieldNotMatch) Error() string { 127 | return fmt.Sprintf("could not find field matching %v", err.questionName) 128 | } 129 | 130 | func (err errFieldNotMatch) Is(target error) bool { // implements the dynamic errors.Is interface. 131 | if target != nil { 132 | if name, ok := IsFieldNotMatch(target); ok { 133 | // if have a filled questionName then perform "deeper" comparison. 134 | return name == "" || err.questionName == "" || name == err.questionName 135 | } 136 | } 137 | 138 | return false 139 | } 140 | 141 | // IsFieldNotMatch reports whether an "err" is caused by a non matching field. 142 | // It returns the Question.Name that couldn't be matched with a destination field. 143 | // 144 | // Usage: 145 | // 146 | // if err := survey.Ask(qs, &v); err != nil { 147 | // if name, ok := core.IsFieldNotMatch(err); ok { 148 | // // name is the question name that did not match a field 149 | // } 150 | // } 151 | func IsFieldNotMatch(err error) (string, bool) { 152 | if err != nil { 153 | if v, ok := err.(errFieldNotMatch); ok { 154 | return v.questionName, true 155 | } 156 | } 157 | 158 | return "", false 159 | } 160 | 161 | // BUG(AlecAivazis): the current implementation might cause weird conflicts if there are 162 | // two fields with same name that only differ by casing. 163 | func findField(s reflect.Value, name string) (reflect.Value, reflect.StructField, error) { 164 | 165 | fields := flattenFields(s) 166 | 167 | // first look for matching tags so we can overwrite matching field names 168 | for _, f := range fields { 169 | // the value of the survey tag 170 | tag := f.fieldType.Tag.Get(tagName) 171 | // if the tag matches the name we are looking for 172 | if tag != "" && tag == name { 173 | // then we found our index 174 | return f.value, f.fieldType, nil 175 | } 176 | } 177 | 178 | // then look for matching names 179 | for _, f := range fields { 180 | // if the name of the field matches what we're looking for 181 | if strings.EqualFold(f.fieldType.Name, name) { 182 | return f.value, f.fieldType, nil 183 | } 184 | } 185 | 186 | // we didn't find the field 187 | return reflect.Value{}, reflect.StructField{}, errFieldNotMatch{name} 188 | } 189 | 190 | func flattenFields(s reflect.Value) []reflectField { 191 | sType := s.Type() 192 | numField := sType.NumField() 193 | fields := make([]reflectField, 0, numField) 194 | for i := 0; i < numField; i++ { 195 | fieldType := sType.Field(i) 196 | field := s.Field(i) 197 | 198 | if field.Kind() == reflect.Struct && fieldType.Anonymous { 199 | // field is a promoted structure 200 | fields = append(fields, flattenFields(field)...) 201 | continue 202 | } 203 | fields = append(fields, reflectField{field, fieldType}) 204 | } 205 | return fields 206 | } 207 | 208 | // isList returns true if the element is something we can Len() 209 | func isList(v reflect.Value) bool { 210 | switch v.Type().Kind() { 211 | case reflect.Array, reflect.Slice: 212 | return true 213 | default: 214 | return false 215 | } 216 | } 217 | 218 | // Write takes a value and copies it to the target 219 | func copy(t reflect.Value, v reflect.Value) (err error) { 220 | // if something ends up panicing we need to catch it in a deferred func 221 | defer func() { 222 | if r := recover(); r != nil { 223 | // if we paniced with an error 224 | if _, ok := r.(error); ok { 225 | // cast the result to an error object 226 | err = r.(error) 227 | } else if _, ok := r.(string); ok { 228 | // otherwise we could have paniced with a string so wrap it in an error 229 | err = errors.New(r.(string)) 230 | } 231 | } 232 | }() 233 | 234 | // if we are copying from a string result to something else 235 | if v.Kind() == reflect.String && v.Type() != t.Type() { 236 | var castVal interface{} 237 | var casterr error 238 | vString := v.Interface().(string) 239 | 240 | switch t.Kind() { 241 | case reflect.Bool: 242 | castVal, casterr = strconv.ParseBool(vString) 243 | case reflect.Int: 244 | castVal, casterr = strconv.Atoi(vString) 245 | case reflect.Int8: 246 | var val64 int64 247 | val64, casterr = strconv.ParseInt(vString, 10, 8) 248 | if casterr == nil { 249 | castVal = int8(val64) 250 | } 251 | case reflect.Int16: 252 | var val64 int64 253 | val64, casterr = strconv.ParseInt(vString, 10, 16) 254 | if casterr == nil { 255 | castVal = int16(val64) 256 | } 257 | case reflect.Int32: 258 | var val64 int64 259 | val64, casterr = strconv.ParseInt(vString, 10, 32) 260 | if casterr == nil { 261 | castVal = int32(val64) 262 | } 263 | case reflect.Int64: 264 | if t.Type() == reflect.TypeOf(time.Duration(0)) { 265 | castVal, casterr = time.ParseDuration(vString) 266 | } else { 267 | castVal, casterr = strconv.ParseInt(vString, 10, 64) 268 | } 269 | case reflect.Uint: 270 | var val64 uint64 271 | val64, casterr = strconv.ParseUint(vString, 10, 8) 272 | if casterr == nil { 273 | castVal = uint(val64) 274 | } 275 | case reflect.Uint8: 276 | var val64 uint64 277 | val64, casterr = strconv.ParseUint(vString, 10, 8) 278 | if casterr == nil { 279 | castVal = uint8(val64) 280 | } 281 | case reflect.Uint16: 282 | var val64 uint64 283 | val64, casterr = strconv.ParseUint(vString, 10, 16) 284 | if casterr == nil { 285 | castVal = uint16(val64) 286 | } 287 | case reflect.Uint32: 288 | var val64 uint64 289 | val64, casterr = strconv.ParseUint(vString, 10, 32) 290 | if casterr == nil { 291 | castVal = uint32(val64) 292 | } 293 | case reflect.Uint64: 294 | castVal, casterr = strconv.ParseUint(vString, 10, 64) 295 | case reflect.Float32: 296 | var val64 float64 297 | val64, casterr = strconv.ParseFloat(vString, 32) 298 | if casterr == nil { 299 | castVal = float32(val64) 300 | } 301 | case reflect.Float64: 302 | castVal, casterr = strconv.ParseFloat(vString, 64) 303 | default: 304 | //lint:ignore ST1005 allow this error message to be capitalized 305 | return fmt.Errorf("Unable to convert from string to type %s", t.Kind()) 306 | } 307 | 308 | if casterr != nil { 309 | return casterr 310 | } 311 | 312 | t.Set(reflect.ValueOf(castVal)) 313 | return 314 | } 315 | 316 | // if we are copying from an OptionAnswer to something 317 | if v.Type().Name() == "OptionAnswer" { 318 | // copying an option answer to a string 319 | if t.Kind() == reflect.String { 320 | // copies the Value field of the struct 321 | t.Set(reflect.ValueOf(v.FieldByName("Value").Interface())) 322 | return 323 | } 324 | 325 | // copying an option answer to an int 326 | if t.Kind() == reflect.Int { 327 | // copies the Index field of the struct 328 | t.Set(reflect.ValueOf(v.FieldByName("Index").Interface())) 329 | return 330 | } 331 | 332 | // copying an OptionAnswer to an OptionAnswer 333 | if t.Type().Name() == "OptionAnswer" { 334 | t.Set(v) 335 | return 336 | } 337 | 338 | // we're copying an option answer to an incorrect type 339 | //lint:ignore ST1005 allow this error message to be capitalized 340 | return fmt.Errorf("Unable to convert from OptionAnswer to type %s", t.Kind()) 341 | } 342 | 343 | // if we are copying from one slice or array to another 344 | if isList(v) && isList(t) { 345 | // loop over every item in the desired value 346 | for i := 0; i < v.Len(); i++ { 347 | // write to the target given its kind 348 | switch t.Kind() { 349 | // if its a slice 350 | case reflect.Slice: 351 | // an object of the correct type 352 | obj := reflect.Indirect(reflect.New(t.Type().Elem())) 353 | 354 | // write the appropriate value to the obj and catch any errors 355 | if err := copy(obj, v.Index(i)); err != nil { 356 | return err 357 | } 358 | 359 | // just append the value to the end 360 | t.Set(reflect.Append(t, obj)) 361 | // otherwise it could be an array 362 | case reflect.Array: 363 | // set the index to the appropriate value 364 | if err := copy(t.Slice(i, i+1).Index(0), v.Index(i)); err != nil { 365 | return err 366 | } 367 | } 368 | } 369 | } else { 370 | // set the value to the target 371 | t.Set(v) 372 | } 373 | 374 | // we're done 375 | return 376 | } 377 | -------------------------------------------------------------------------------- /multiselect.go: -------------------------------------------------------------------------------- 1 | package survey 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/AlecAivazis/survey/v2/core" 8 | "github.com/AlecAivazis/survey/v2/terminal" 9 | ) 10 | 11 | /* 12 | MultiSelect is a prompt that presents a list of various options to the user 13 | for them to select using the arrow keys and enter. Response type is a slice of strings. 14 | 15 | days := []string{} 16 | prompt := &survey.MultiSelect{ 17 | Message: "What days do you prefer:", 18 | Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}, 19 | } 20 | survey.AskOne(prompt, &days) 21 | */ 22 | type MultiSelect struct { 23 | Renderer 24 | Message string 25 | Options []string 26 | Default interface{} 27 | Help string 28 | PageSize int 29 | VimMode bool 30 | FilterMessage string 31 | Filter func(filter string, value string, index int) bool 32 | Description func(value string, index int) string 33 | filter string 34 | selectedIndex int 35 | checked map[int]bool 36 | showingHelp bool 37 | } 38 | 39 | // data available to the templates when processing 40 | type MultiSelectTemplateData struct { 41 | MultiSelect 42 | Answer string 43 | ShowAnswer bool 44 | Checked map[int]bool 45 | SelectedIndex int 46 | ShowHelp bool 47 | Description func(value string, index int) string 48 | PageEntries []core.OptionAnswer 49 | Config *PromptConfig 50 | 51 | // These fields are used when rendering an individual option 52 | CurrentOpt core.OptionAnswer 53 | CurrentIndex int 54 | } 55 | 56 | // IterateOption sets CurrentOpt and CurrentIndex appropriately so a multiselect option can be rendered individually 57 | func (m MultiSelectTemplateData) IterateOption(ix int, opt core.OptionAnswer) interface{} { 58 | copy := m 59 | copy.CurrentIndex = ix 60 | copy.CurrentOpt = opt 61 | return copy 62 | } 63 | 64 | func (m MultiSelectTemplateData) GetDescription(opt core.OptionAnswer) string { 65 | if m.Description == nil { 66 | return "" 67 | } 68 | return m.Description(opt.Value, opt.Index) 69 | } 70 | 71 | var MultiSelectQuestionTemplate = ` 72 | {{- define "option"}} 73 | {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}} 74 | {{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}} 75 | {{- color "reset"}} 76 | {{- " "}}{{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{color "reset"}}{{end}} 77 | {{end}} 78 | {{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} 79 | {{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} 80 | {{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} 81 | {{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} 82 | {{- else }} 83 | {{- " "}}{{- color "cyan"}}[Use arrows to move, space to select,{{- if not .Config.RemoveSelectAll }} to all,{{end}}{{- if not .Config.RemoveSelectNone }} to none,{{end}} type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}} 84 | {{- "\n"}} 85 | {{- range $ix, $option := .PageEntries}} 86 | {{- template "option" $.IterateOption $ix $option}} 87 | {{- end}} 88 | {{- end}}` 89 | 90 | // OnChange is called on every keypress. 91 | func (m *MultiSelect) OnChange(key rune, config *PromptConfig) { 92 | options := m.filterOptions(config) 93 | oldFilter := m.filter 94 | 95 | if key == terminal.KeyArrowUp || (m.VimMode && key == 'k') { 96 | // if we are at the top of the list 97 | if m.selectedIndex == 0 { 98 | // go to the bottom 99 | m.selectedIndex = len(options) - 1 100 | } else { 101 | // decrement the selected index 102 | m.selectedIndex-- 103 | } 104 | } else if key == terminal.KeyTab || key == terminal.KeyArrowDown || (m.VimMode && key == 'j') { 105 | // if we are at the bottom of the list 106 | if m.selectedIndex == len(options)-1 { 107 | // start at the top 108 | m.selectedIndex = 0 109 | } else { 110 | // increment the selected index 111 | m.selectedIndex++ 112 | } 113 | // if the user pressed down and there is room to move 114 | } else if key == terminal.KeySpace { 115 | // the option they have selected 116 | if m.selectedIndex < len(options) { 117 | selectedOpt := options[m.selectedIndex] 118 | 119 | // if we haven't seen this index before 120 | if old, ok := m.checked[selectedOpt.Index]; !ok { 121 | // set the value to true 122 | m.checked[selectedOpt.Index] = true 123 | } else { 124 | // otherwise just invert the current value 125 | m.checked[selectedOpt.Index] = !old 126 | } 127 | if !config.KeepFilter { 128 | m.filter = "" 129 | } 130 | } 131 | // only show the help message if we have one to show 132 | } else if string(key) == config.HelpInput && m.Help != "" { 133 | m.showingHelp = true 134 | } else if key == terminal.KeyEscape { 135 | m.VimMode = !m.VimMode 136 | } else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine { 137 | m.filter = "" 138 | } else if key == terminal.KeyDelete || key == terminal.KeyBackspace { 139 | if m.filter != "" { 140 | runeFilter := []rune(m.filter) 141 | m.filter = string(runeFilter[0 : len(runeFilter)-1]) 142 | } 143 | } else if key >= terminal.KeySpace { 144 | m.filter += string(key) 145 | m.VimMode = false 146 | } else if !config.RemoveSelectAll && key == terminal.KeyArrowRight { 147 | for _, v := range options { 148 | m.checked[v.Index] = true 149 | } 150 | if !config.KeepFilter { 151 | m.filter = "" 152 | } 153 | } else if !config.RemoveSelectNone && key == terminal.KeyArrowLeft { 154 | for _, v := range options { 155 | m.checked[v.Index] = false 156 | } 157 | if !config.KeepFilter { 158 | m.filter = "" 159 | } 160 | } 161 | 162 | m.FilterMessage = "" 163 | if m.filter != "" { 164 | m.FilterMessage = " " + m.filter 165 | } 166 | if oldFilter != m.filter { 167 | // filter changed 168 | options = m.filterOptions(config) 169 | if len(options) > 0 && len(options) <= m.selectedIndex { 170 | m.selectedIndex = len(options) - 1 171 | } 172 | } 173 | // paginate the options 174 | // figure out the page size 175 | pageSize := m.PageSize 176 | // if we dont have a specific one 177 | if pageSize == 0 { 178 | // grab the global value 179 | pageSize = config.PageSize 180 | } 181 | 182 | // TODO if we have started filtering and were looking at the end of a list 183 | // and we have modified the filter then we should move the page back! 184 | opts, idx := paginate(pageSize, options, m.selectedIndex) 185 | 186 | tmplData := MultiSelectTemplateData{ 187 | MultiSelect: *m, 188 | SelectedIndex: idx, 189 | Checked: m.checked, 190 | ShowHelp: m.showingHelp, 191 | Description: m.Description, 192 | PageEntries: opts, 193 | Config: config, 194 | } 195 | 196 | // render the options 197 | _ = m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx) 198 | } 199 | 200 | func (m *MultiSelect) filterOptions(config *PromptConfig) []core.OptionAnswer { 201 | // the filtered list 202 | answers := []core.OptionAnswer{} 203 | 204 | // if there is no filter applied 205 | if m.filter == "" { 206 | // return all of the options 207 | return core.OptionAnswerList(m.Options) 208 | } 209 | 210 | // the filter to apply 211 | filter := m.Filter 212 | if filter == nil { 213 | filter = config.Filter 214 | } 215 | 216 | // apply the filter to each option 217 | for i, opt := range m.Options { 218 | // i the filter says to include the option 219 | if filter(m.filter, opt, i) { 220 | answers = append(answers, core.OptionAnswer{ 221 | Index: i, 222 | Value: opt, 223 | }) 224 | } 225 | } 226 | 227 | // we're done here 228 | return answers 229 | } 230 | 231 | func (m *MultiSelect) Prompt(config *PromptConfig) (interface{}, error) { 232 | // compute the default state 233 | m.checked = make(map[int]bool) 234 | // if there is a default 235 | if m.Default != nil { 236 | // if the default is string values 237 | if defaultValues, ok := m.Default.([]string); ok { 238 | for _, dflt := range defaultValues { 239 | for i, opt := range m.Options { 240 | // if the option corresponds to the default 241 | if opt == dflt { 242 | // we found our initial value 243 | m.checked[i] = true 244 | // stop looking 245 | break 246 | } 247 | } 248 | } 249 | // if the default value is index values 250 | } else if defaultIndices, ok := m.Default.([]int); ok { 251 | // go over every index we need to enable by default 252 | for _, idx := range defaultIndices { 253 | // and enable it 254 | m.checked[idx] = true 255 | } 256 | } 257 | } 258 | 259 | // if there are no options to render 260 | if len(m.Options) == 0 { 261 | // we failed 262 | return "", errors.New("please provide options to select from") 263 | } 264 | 265 | // figure out the page size 266 | pageSize := m.PageSize 267 | // if we dont have a specific one 268 | if pageSize == 0 { 269 | // grab the global value 270 | pageSize = config.PageSize 271 | } 272 | // paginate the options 273 | // build up a list of option answers 274 | opts, idx := paginate(pageSize, core.OptionAnswerList(m.Options), m.selectedIndex) 275 | 276 | cursor := m.NewCursor() 277 | cursor.Save() // for proper cursor placement during selection 278 | cursor.Hide() // hide the cursor 279 | defer cursor.Show() // show the cursor when we're done 280 | defer cursor.Restore() // clear any accessibility offsetting on exit 281 | 282 | tmplData := MultiSelectTemplateData{ 283 | MultiSelect: *m, 284 | SelectedIndex: idx, 285 | Description: m.Description, 286 | Checked: m.checked, 287 | PageEntries: opts, 288 | Config: config, 289 | } 290 | 291 | // ask the question 292 | err := m.RenderWithCursorOffset(MultiSelectQuestionTemplate, tmplData, opts, idx) 293 | if err != nil { 294 | return "", err 295 | } 296 | 297 | rr := m.NewRuneReader() 298 | _ = rr.SetTermMode() 299 | defer func() { 300 | _ = rr.RestoreTermMode() 301 | }() 302 | 303 | // start waiting for input 304 | for { 305 | r, _, err := rr.ReadRune() 306 | if err != nil { 307 | return "", err 308 | } 309 | if r == '\r' || r == '\n' { 310 | break 311 | } 312 | if r == terminal.KeyInterrupt { 313 | return "", terminal.InterruptErr 314 | } 315 | if r == terminal.KeyEndTransmission { 316 | break 317 | } 318 | m.OnChange(r, config) 319 | } 320 | m.filter = "" 321 | m.FilterMessage = "" 322 | 323 | answers := []core.OptionAnswer{} 324 | for i, option := range m.Options { 325 | if val, ok := m.checked[i]; ok && val { 326 | answers = append(answers, core.OptionAnswer{Value: option, Index: i}) 327 | } 328 | } 329 | 330 | return answers, nil 331 | } 332 | 333 | // Cleanup removes the options section, and renders the ask like a normal question. 334 | func (m *MultiSelect) Cleanup(config *PromptConfig, val interface{}) error { 335 | // the answer to show 336 | answer := "" 337 | for _, ans := range val.([]core.OptionAnswer) { 338 | answer = fmt.Sprintf("%s, %s", answer, ans.Value) 339 | } 340 | 341 | // if we answered anything 342 | if len(answer) > 2 { 343 | // remove the precending commas 344 | answer = answer[2:] 345 | } 346 | 347 | // execute the output summary template with the answer 348 | return m.Render( 349 | MultiSelectQuestionTemplate, 350 | MultiSelectTemplateData{ 351 | MultiSelect: *m, 352 | SelectedIndex: m.selectedIndex, 353 | Checked: m.checked, 354 | Answer: answer, 355 | ShowAnswer: true, 356 | Description: m.Description, 357 | Config: config, 358 | }, 359 | ) 360 | } 361 | -------------------------------------------------------------------------------- /terminal/runereader.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "fmt" 5 | "unicode" 6 | 7 | "golang.org/x/text/width" 8 | ) 9 | 10 | type RuneReader struct { 11 | stdio Stdio 12 | state runeReaderState 13 | } 14 | 15 | func NewRuneReader(stdio Stdio) *RuneReader { 16 | return &RuneReader{ 17 | stdio: stdio, 18 | state: newRuneReaderState(stdio.In), 19 | } 20 | } 21 | 22 | func (rr *RuneReader) printChar(char rune, mask rune) error { 23 | // if we don't need to mask the input 24 | if mask == 0 { 25 | // just print the character the user pressed 26 | _, err := fmt.Fprintf(rr.stdio.Out, "%c", char) 27 | return err 28 | } 29 | // otherwise print the mask we were given 30 | _, err := fmt.Fprintf(rr.stdio.Out, "%c", mask) 31 | return err 32 | } 33 | 34 | type OnRuneFn func(rune, []rune) ([]rune, bool, error) 35 | 36 | func (rr *RuneReader) ReadLine(mask rune, onRunes ...OnRuneFn) ([]rune, error) { 37 | return rr.ReadLineWithDefault(mask, []rune{}, onRunes...) 38 | } 39 | 40 | func (rr *RuneReader) ReadLineWithDefault(mask rune, d []rune, onRunes ...OnRuneFn) ([]rune, error) { 41 | line := []rune{} 42 | // we only care about horizontal displacements from the origin so start counting at 0 43 | index := 0 44 | 45 | cursor := &Cursor{ 46 | In: rr.stdio.In, 47 | Out: rr.stdio.Out, 48 | } 49 | 50 | onRune := func(r rune, line []rune) ([]rune, bool, error) { 51 | return line, false, nil 52 | } 53 | 54 | // if the user pressed a key the caller was interested in capturing 55 | if len(onRunes) > 0 { 56 | onRune = onRunes[0] 57 | } 58 | 59 | // we get the terminal width and height (if resized after this point the property might become invalid) 60 | terminalSize, _ := cursor.Size(rr.Buffer()) 61 | // we set the current location of the cursor once 62 | cursorCurrent, _ := cursor.Location(rr.Buffer()) 63 | 64 | increment := func() { 65 | if cursorCurrent.CursorIsAtLineEnd(terminalSize) { 66 | cursorCurrent.X = COORDINATE_SYSTEM_BEGIN 67 | cursorCurrent.Y++ 68 | } else { 69 | cursorCurrent.X++ 70 | } 71 | } 72 | decrement := func() { 73 | if cursorCurrent.CursorIsAtLineBegin() { 74 | cursorCurrent.X = terminalSize.X 75 | cursorCurrent.Y-- 76 | } else { 77 | cursorCurrent.X-- 78 | } 79 | } 80 | 81 | if len(d) > 0 { 82 | index = len(d) 83 | if _, err := fmt.Fprint(rr.stdio.Out, string(d)); err != nil { 84 | return d, err 85 | } 86 | line = d 87 | for range d { 88 | increment() 89 | } 90 | } 91 | 92 | for { 93 | // wait for some input 94 | r, _, err := rr.ReadRune() 95 | if err != nil { 96 | return line, err 97 | } 98 | 99 | if l, stop, err := onRune(r, line); stop || err != nil { 100 | return l, err 101 | } 102 | 103 | // if the user pressed enter or some other newline/termination like ctrl+d 104 | if r == '\r' || r == '\n' || r == KeyEndTransmission { 105 | // delete what's printed out on the console screen (cleanup) 106 | for index > 0 { 107 | if cursorCurrent.CursorIsAtLineBegin() { 108 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 109 | cursor.PreviousLine(1) 110 | cursor.Forward(int(terminalSize.X)) 111 | } else { 112 | cursor.Back(1) 113 | } 114 | decrement() 115 | index-- 116 | } 117 | // move the cursor the a new line 118 | cursor.MoveNextLine(cursorCurrent, terminalSize) 119 | 120 | // we're done processing the input 121 | return line, nil 122 | } 123 | // if the user interrupts (ie with ctrl+c) 124 | if r == KeyInterrupt { 125 | // go to the beginning of the next line 126 | if _, err := fmt.Fprint(rr.stdio.Out, "\r\n"); err != nil { 127 | return line, err 128 | } 129 | 130 | // we're done processing the input, and treat interrupt like an error 131 | return line, InterruptErr 132 | } 133 | 134 | // allow for backspace/delete editing of inputs 135 | if r == KeyBackspace || r == KeyDelete { 136 | // and we're not at the beginning of the line 137 | if index > 0 && len(line) > 0 { 138 | // if we are at the end of the word 139 | if index == len(line) { 140 | // just remove the last letter from the internal representation 141 | // also count the number of cells the rune before the cursor occupied 142 | cells := runeWidth(line[len(line)-1]) 143 | line = line[:len(line)-1] 144 | // go back one 145 | if cursorCurrent.X == 1 { 146 | cursor.PreviousLine(1) 147 | cursor.Forward(int(terminalSize.X)) 148 | } else { 149 | cursor.Back(cells) 150 | } 151 | 152 | // clear the rest of the line 153 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 154 | } else { 155 | // we need to remove a character from the middle of the word 156 | 157 | cells := runeWidth(line[index-1]) 158 | 159 | // remove the current index from the list 160 | line = append(line[:index-1], line[index:]...) 161 | 162 | // save the current position of the cursor, as we have to move the cursor one back to erase the current symbol 163 | // and then move the cursor for each symbol in line[index-1:] to print it out, afterwards we want to restore 164 | // the cursor to its previous location. 165 | cursor.Save() 166 | 167 | // clear the rest of the line 168 | cursor.Back(cells) 169 | 170 | // print what comes after 171 | for _, char := range line[index-1:] { 172 | //Erase symbols which are left over from older print 173 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 174 | // print characters to the new line appropriately 175 | if err := rr.printChar(char, mask); err != nil { 176 | return line, err 177 | } 178 | } 179 | // erase what's left over from last print 180 | if cursorCurrent.Y < terminalSize.Y { 181 | cursor.NextLine(1) 182 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 183 | } 184 | // restore cursor 185 | cursor.Restore() 186 | if cursorCurrent.CursorIsAtLineBegin() { 187 | cursor.PreviousLine(1) 188 | cursor.Forward(int(terminalSize.X)) 189 | } else { 190 | cursor.Back(cells) 191 | } 192 | } 193 | 194 | // decrement the index 195 | index-- 196 | decrement() 197 | } else { 198 | // otherwise the user pressed backspace while at the beginning of the line 199 | _ = soundBell(rr.stdio.Out) 200 | } 201 | 202 | // we're done processing this key 203 | continue 204 | } 205 | 206 | // if the left arrow is pressed 207 | if r == KeyArrowLeft { 208 | // if we have space to the left 209 | if index > 0 { 210 | //move the cursor to the prev line if necessary 211 | if cursorCurrent.CursorIsAtLineBegin() { 212 | cursor.PreviousLine(1) 213 | cursor.Forward(int(terminalSize.X)) 214 | } else { 215 | cursor.Back(runeWidth(line[index-1])) 216 | } 217 | //decrement the index 218 | index-- 219 | decrement() 220 | 221 | } else { 222 | // otherwise we are at the beginning of where we started reading lines 223 | // sound the bell 224 | _ = soundBell(rr.stdio.Out) 225 | } 226 | 227 | // we're done processing this key press 228 | continue 229 | } 230 | 231 | // if the right arrow is pressed 232 | if r == KeyArrowRight { 233 | // if we have space to the right 234 | if index < len(line) { 235 | // move the cursor to the next line if necessary 236 | if cursorCurrent.CursorIsAtLineEnd(terminalSize) { 237 | cursor.NextLine(1) 238 | } else { 239 | cursor.Forward(runeWidth(line[index])) 240 | } 241 | index++ 242 | increment() 243 | 244 | } else { 245 | // otherwise we are at the end of the word and can't go past 246 | // sound the bell 247 | _ = soundBell(rr.stdio.Out) 248 | } 249 | 250 | // we're done processing this key press 251 | continue 252 | } 253 | // the user pressed one of the special keys 254 | if r == SpecialKeyHome { 255 | for index > 0 { 256 | if cursorCurrent.CursorIsAtLineBegin() { 257 | cursor.PreviousLine(1) 258 | cursor.Forward(int(terminalSize.X)) 259 | cursorCurrent.Y-- 260 | cursorCurrent.X = terminalSize.X 261 | } else { 262 | cursor.Back(runeWidth(line[index-1])) 263 | cursorCurrent.X -= Short(runeWidth(line[index-1])) 264 | } 265 | index-- 266 | } 267 | continue 268 | // user pressed end 269 | } else if r == SpecialKeyEnd { 270 | for index != len(line) { 271 | if cursorCurrent.CursorIsAtLineEnd(terminalSize) { 272 | cursor.NextLine(1) 273 | cursorCurrent.Y++ 274 | cursorCurrent.X = COORDINATE_SYSTEM_BEGIN 275 | } else { 276 | cursor.Forward(runeWidth(line[index])) 277 | cursorCurrent.X += Short(runeWidth(line[index])) 278 | } 279 | index++ 280 | } 281 | continue 282 | // user pressed forward delete key 283 | } else if r == SpecialKeyDelete { 284 | // if index at the end of the line nothing to delete 285 | if index != len(line) { 286 | // save the current position of the cursor, as we have to erase the current symbol 287 | // and then move the cursor for each symbol in line[index:] to print it out, afterwards we want to restore 288 | // the cursor to its previous location. 289 | cursor.Save() 290 | // remove the symbol after the cursor 291 | line = append(line[:index], line[index+1:]...) 292 | // print the updated line 293 | for _, char := range line[index:] { 294 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 295 | // print out the character 296 | if err := rr.printChar(char, mask); err != nil { 297 | return line, err 298 | } 299 | } 300 | // erase what's left on last line 301 | if cursorCurrent.Y < terminalSize.Y { 302 | cursor.NextLine(1) 303 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 304 | } 305 | // restore cursor 306 | cursor.Restore() 307 | if len(line) == 0 || index == len(line) { 308 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 309 | } 310 | } 311 | continue 312 | } 313 | 314 | // if the letter is another escape sequence 315 | if unicode.IsControl(r) || r == IgnoreKey { 316 | // ignore it 317 | continue 318 | } 319 | 320 | // the user pressed a regular key 321 | 322 | // if we are at the end of the line 323 | if index == len(line) { 324 | // just append the character at the end of the line 325 | line = append(line, r) 326 | // save the location of the cursor 327 | index++ 328 | increment() 329 | // print out the character 330 | if err := rr.printChar(r, mask); err != nil { 331 | return line, err 332 | } 333 | } else { 334 | // we are in the middle of the word so we need to insert the character the user pressed 335 | line = append(line[:index], append([]rune{r}, line[index:]...)...) 336 | // save the current position of the cursor, as we have to move the cursor back to erase the current symbol 337 | // and then move for each symbol in line[index:] to print it out, afterwards we want to restore 338 | // cursor's location to its previous one. 339 | cursor.Save() 340 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 341 | // remove the symbol after the cursor 342 | // print the updated line 343 | for _, char := range line[index:] { 344 | EraseLine(rr.stdio.Out, ERASE_LINE_END) 345 | // print out the character 346 | if err := rr.printChar(char, mask); err != nil { 347 | return line, err 348 | } 349 | increment() 350 | } 351 | // if we are at the last line, we want to visually insert a new line and append to it. 352 | if cursorCurrent.CursorIsAtLineEnd(terminalSize) && cursorCurrent.Y == terminalSize.Y { 353 | // add a new line to the terminal 354 | if _, err := fmt.Fprintln(rr.stdio.Out); err != nil { 355 | return line, err 356 | } 357 | // restore the position of the cursor horizontally 358 | cursor.Restore() 359 | // restore the position of the cursor vertically 360 | cursor.PreviousLine(1) 361 | } else { 362 | // restore cursor 363 | cursor.Restore() 364 | } 365 | // check if cursor needs to move to next line 366 | cursorCurrent, _ = cursor.Location(rr.Buffer()) 367 | if cursorCurrent.CursorIsAtLineEnd(terminalSize) { 368 | cursor.NextLine(1) 369 | } else { 370 | cursor.Forward(runeWidth(r)) 371 | } 372 | // increment the index 373 | index++ 374 | increment() 375 | 376 | } 377 | } 378 | } 379 | 380 | // runeWidth returns the number of columns spanned by a rune when printed to the terminal 381 | func runeWidth(r rune) int { 382 | switch width.LookupRune(r).Kind() { 383 | case width.EastAsianWide, width.EastAsianFullwidth: 384 | return 2 385 | } 386 | 387 | if !unicode.IsPrint(r) { 388 | return 0 389 | } 390 | return 1 391 | } 392 | 393 | // isAnsiMarker returns if a rune denotes the start of an ANSI sequence 394 | func isAnsiMarker(r rune) bool { 395 | return r == '\x1B' 396 | } 397 | 398 | // isAnsiTerminator returns if a rune denotes the end of an ANSI sequence 399 | func isAnsiTerminator(r rune) bool { 400 | return (r >= 0x40 && r <= 0x5a) || (r == 0x5e) || (r >= 0x60 && r <= 0x7e) 401 | } 402 | 403 | // StringWidth returns the visible width of a string when printed to the terminal 404 | func StringWidth(str string) int { 405 | w := 0 406 | ansi := false 407 | 408 | for _, r := range str { 409 | // increase width only when outside of ANSI escape sequences 410 | if ansi || isAnsiMarker(r) { 411 | ansi = !isAnsiTerminator(r) 412 | } else { 413 | w += runeWidth(r) 414 | } 415 | } 416 | return w 417 | } 418 | --------------------------------------------------------------------------------