├── .gitignore ├── .github ├── listbot.md └── CONTRIBUTING.md ├── keycodes_other.go ├── keycodes_windows.go ├── .travis.yml ├── go.mod ├── _examples ├── confirm │ └── main.go ├── select │ └── main.go ├── prompt │ └── main.go ├── prompt_password │ └── main.go ├── select_add │ └── main.go ├── prompt_default │ └── main.go ├── custom_prompt │ └── main.go └── custom_select │ └── main.go ├── .golangci.yml ├── example_selectwithadd_test.go ├── go.sum ├── codes_test.go ├── styles.go ├── styles_windows.go ├── keycodes.go ├── promptui.go ├── example_prompt_test.go ├── Makefile ├── example_main_test.go ├── LICENSE.md ├── example_select_test.go ├── README.md ├── CHANGELOG.md ├── cursor_test.go ├── CODE_OF_CONDUCT.md ├── codes.go ├── screenbuf ├── screenbuf_test.go └── screenbuf.go ├── list ├── list_test.go └── list.go ├── select_test.go ├── cursor.go ├── prompt.go └── select.go /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | all-cover.txt 3 | bin/ 4 | -------------------------------------------------------------------------------- /.github/listbot.md: -------------------------------------------------------------------------------- 1 | **Author** 2 | 3 | - [ ] Changelog has been updated 4 | -------------------------------------------------------------------------------- /keycodes_other.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package promptui 4 | 5 | import "github.com/chzyer/readline" 6 | 7 | var ( 8 | // KeyBackspace is the default key for deleting input text. 9 | KeyBackspace rune = readline.CharBackspace 10 | ) 11 | -------------------------------------------------------------------------------- /keycodes_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package promptui 4 | 5 | // source: https://msdn.microsoft.com/en-us/library/aa243025(v=vs.60).aspx 6 | 7 | var ( 8 | // KeyBackspace is the default key for deleting input text inside a command line prompt. 9 | KeyBackspace rune = 8 10 | ) 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: go 3 | 4 | go: 5 | - "1.12.x" 6 | - "1.13.x" 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | after_success: 13 | # only report coverage for go-version 1.11 14 | - if [[ $TRAVIS_GO_VERSION =~ ^1\.11 ]] ; then bash <(curl -s https://codecov.io/bash) -f all-cover.txt; fi 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/manifoldco/promptui 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/chzyer/logex v1.1.10 // indirect 7 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e 8 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect 9 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /_examples/confirm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/manifoldco/promptui" 7 | ) 8 | 9 | func main() { 10 | prompt := promptui.Prompt{ 11 | Label: "Delete Resource", 12 | IsConfirm: true, 13 | } 14 | 15 | result, err := prompt.Run() 16 | 17 | if err != nil { 18 | fmt.Printf("Prompt failed %v\n", err) 19 | return 20 | } 21 | 22 | fmt.Printf("You choose %q\n", result) 23 | } 24 | -------------------------------------------------------------------------------- /_examples/select/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/manifoldco/promptui" 7 | ) 8 | 9 | func main() { 10 | prompt := promptui.Select{ 11 | Label: "Select Day", 12 | Items: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", 13 | "Saturday", "Sunday"}, 14 | } 15 | 16 | _, result, err := prompt.Run() 17 | 18 | if err != nil { 19 | fmt.Printf("Prompt failed %v\n", err) 20 | return 21 | } 22 | 23 | fmt.Printf("You choose %q\n", result) 24 | } 25 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 5m 3 | 4 | issues: 5 | # Disable maximums so we see all issues 6 | max-per-linter: 0 7 | max-same-issues: 0 8 | 9 | # golangci-lint ignores missing docstrings by default. That's no good! 10 | exclude-use-default: false 11 | 12 | linters: 13 | disable-all: true 14 | enable: 15 | - misspell 16 | - golint 17 | - goimports 18 | - ineffassign 19 | - deadcode 20 | - gofmt 21 | - govet 22 | - structcheck 23 | - unconvert 24 | - megacheck 25 | - typecheck 26 | - varcheck 27 | -------------------------------------------------------------------------------- /_examples/prompt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/manifoldco/promptui" 9 | ) 10 | 11 | func main() { 12 | validate := func(input string) error { 13 | _, err := strconv.ParseFloat(input, 64) 14 | if err != nil { 15 | return errors.New("Invalid number") 16 | } 17 | return nil 18 | } 19 | 20 | prompt := promptui.Prompt{ 21 | Label: "Number", 22 | Validate: validate, 23 | } 24 | 25 | result, err := prompt.Run() 26 | 27 | if err != nil { 28 | fmt.Printf("Prompt failed %v\n", err) 29 | return 30 | } 31 | 32 | fmt.Printf("You choose %q\n", result) 33 | } 34 | -------------------------------------------------------------------------------- /_examples/prompt_password/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/manifoldco/promptui" 8 | ) 9 | 10 | func main() { 11 | validate := func(input string) error { 12 | if len(input) < 6 { 13 | return errors.New("Password must have more than 6 characters") 14 | } 15 | return nil 16 | } 17 | 18 | prompt := promptui.Prompt{ 19 | Label: "Password", 20 | Validate: validate, 21 | Mask: '*', 22 | } 23 | 24 | result, err := prompt.Run() 25 | 26 | if err != nil { 27 | fmt.Printf("Prompt failed %v\n", err) 28 | return 29 | } 30 | 31 | fmt.Printf("Your password is %q\n", result) 32 | } 33 | -------------------------------------------------------------------------------- /_examples/select_add/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/manifoldco/promptui" 7 | ) 8 | 9 | func main() { 10 | items := []string{"Vim", "Emacs", "Sublime", "VSCode", "Atom"} 11 | index := -1 12 | var result string 13 | var err error 14 | 15 | for index < 0 { 16 | prompt := promptui.SelectWithAdd{ 17 | Label: "What's your text editor", 18 | Items: items, 19 | AddLabel: "Other", 20 | } 21 | 22 | index, result, err = prompt.Run() 23 | 24 | if index == -1 { 25 | items = append(items, result) 26 | } 27 | } 28 | 29 | if err != nil { 30 | fmt.Printf("Prompt failed %v\n", err) 31 | return 32 | } 33 | 34 | fmt.Printf("You choose %s\n", result) 35 | } 36 | -------------------------------------------------------------------------------- /_examples/prompt_default/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/user" 7 | 8 | "github.com/manifoldco/promptui" 9 | ) 10 | 11 | func main() { 12 | validate := func(input string) error { 13 | if len(input) < 3 { 14 | return errors.New("Username must have more than 3 characters") 15 | } 16 | return nil 17 | } 18 | 19 | var username string 20 | u, err := user.Current() 21 | if err == nil { 22 | username = u.Username 23 | } 24 | 25 | prompt := promptui.Prompt{ 26 | Label: "Username", 27 | Validate: validate, 28 | Default: username, 29 | } 30 | 31 | result, err := prompt.Run() 32 | 33 | if err != nil { 34 | fmt.Printf("Prompt failed %v\n", err) 35 | return 36 | } 37 | 38 | fmt.Printf("Your username is %q\n", result) 39 | } 40 | -------------------------------------------------------------------------------- /example_selectwithadd_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import "fmt" 4 | 5 | // This example shows how to create a SelectWithAdd that will add each new item it is given to the 6 | // list of items until one is chosen. 7 | func ExampleSelectWithAdd() { 8 | items := []string{"Vim", "Emacs", "Sublime", "VSCode", "Atom"} 9 | index := -1 10 | var result string 11 | var err error 12 | 13 | for index < 0 { 14 | prompt := SelectWithAdd{ 15 | Label: "What's your text editor", 16 | Items: items, 17 | AddLabel: "Add your own", 18 | } 19 | 20 | index, result, err = prompt.Run() 21 | 22 | if index == -1 { 23 | items = append(items, result) 24 | } 25 | } 26 | 27 | if err != nil { 28 | fmt.Printf("Prompt failed %v\n", err) 29 | return 30 | } 31 | 32 | fmt.Printf("You choose %s\n", result) 33 | } 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 2 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 3 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 4 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 5 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 6 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 7 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= 8 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 9 | -------------------------------------------------------------------------------- /_examples/custom_prompt/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/manifoldco/promptui" 8 | ) 9 | 10 | type pepper struct { 11 | Name string 12 | HeatUnit int 13 | Peppers int 14 | } 15 | 16 | func main() { 17 | validate := func(input string) error { 18 | _, err := strconv.ParseFloat(input, 64) 19 | return err 20 | } 21 | 22 | templates := &promptui.PromptTemplates{ 23 | Prompt: "{{ . }} ", 24 | Valid: "{{ . | green }} ", 25 | Invalid: "{{ . | red }} ", 26 | Success: "{{ . | bold }} ", 27 | } 28 | 29 | prompt := promptui.Prompt{ 30 | Label: "Spicy Level", 31 | Templates: templates, 32 | Validate: validate, 33 | } 34 | 35 | result, err := prompt.Run() 36 | 37 | if err != nil { 38 | fmt.Printf("Prompt failed %v\n", err) 39 | return 40 | } 41 | 42 | fmt.Printf("You answered %s\n", result) 43 | } 44 | -------------------------------------------------------------------------------- /codes_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import "testing" 4 | 5 | func TestStyler(t *testing.T) { 6 | t.Run("renders a single code", func(t *testing.T) { 7 | red := Styler(FGRed)("hi") 8 | expected := "\033[31mhi\033[0m" 9 | if red != expected { 10 | t.Errorf("style did not match: %s != %s", red, expected) 11 | } 12 | }) 13 | 14 | t.Run("combines multiple codes", func(t *testing.T) { 15 | boldRed := Styler(FGRed, FGBold)("hi") 16 | expected := "\033[31;1mhi\033[0m" 17 | if boldRed != expected { 18 | t.Errorf("style did not match: %s != %s", boldRed, expected) 19 | } 20 | }) 21 | 22 | t.Run("should not repeat reset codes for nested styles", func(t *testing.T) { 23 | red := Styler(FGRed)("hi") 24 | boldRed := Styler(FGBold)(red) 25 | expected := "\033[1m\033[31mhi\033[0m" 26 | if boldRed != expected { 27 | t.Errorf("style did not match: %s != %s", boldRed, expected) 28 | } 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /styles.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package promptui 4 | 5 | // These are the default icons used by promptui for select and prompts. These should not be overridden and instead 6 | // customized through the use of custom templates 7 | var ( 8 | // IconInitial is the icon used when starting in prompt mode and the icon next to the label when 9 | // starting in select mode. 10 | IconInitial = Styler(FGBlue)("?") 11 | 12 | // IconGood is the icon used when a good answer is entered in prompt mode. 13 | IconGood = Styler(FGGreen)("✔") 14 | 15 | // IconWarn is the icon used when a good, but potentially invalid answer is entered in prompt mode. 16 | IconWarn = Styler(FGYellow)("⚠") 17 | 18 | // IconBad is the icon used when a bad answer is entered in prompt mode. 19 | IconBad = Styler(FGRed)("✗") 20 | 21 | // IconSelect is the icon used to identify the currently selected item in select mode. 22 | IconSelect = Styler(FGBold)("▸") 23 | ) 24 | -------------------------------------------------------------------------------- /styles_windows.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | // These are the default icons used bu promptui for select and prompts. They can either be overridden directly 4 | // from these variable or customized through the use of custom templates 5 | var ( 6 | // IconInitial is the icon used when starting in prompt mode and the icon next to the label when 7 | // starting in select mode. 8 | IconInitial = Styler(FGBlue)("?") 9 | 10 | // IconGood is the icon used when a good answer is entered in prompt mode. 11 | IconGood = Styler(FGGreen)("v") 12 | 13 | // IconWarn is the icon used when a good, but potentially invalid answer is entered in prompt mode. 14 | IconWarn = Styler(FGYellow)("!") 15 | 16 | // IconBad is the icon used when a bad answer is entered in prompt mode. 17 | IconBad = Styler(FGRed)("x") 18 | 19 | // IconSelect is the icon used to identify the currently selected item in select mode. 20 | IconSelect = Styler(FGBold)(">") 21 | ) 22 | -------------------------------------------------------------------------------- /keycodes.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import "github.com/chzyer/readline" 4 | 5 | // These runes are used to identify the commands entered by the user in the command prompt. They map 6 | // to specific actions of promptui in prompt mode and can be remapped if necessary. 7 | var ( 8 | // KeyEnter is the default key for submission/selection. 9 | KeyEnter rune = readline.CharEnter 10 | 11 | // KeyCtrlH is the key for deleting input text. 12 | KeyCtrlH rune = readline.CharCtrlH 13 | 14 | // KeyPrev is the default key to go up during selection. 15 | KeyPrev rune = readline.CharPrev 16 | KeyPrevDisplay = "↑" 17 | 18 | // KeyNext is the default key to go down during selection. 19 | KeyNext rune = readline.CharNext 20 | KeyNextDisplay = "↓" 21 | 22 | // KeyBackward is the default key to page up during selection. 23 | KeyBackward rune = readline.CharBackward 24 | KeyBackwardDisplay = "←" 25 | 26 | // KeyForward is the default key to page down during selection. 27 | KeyForward rune = readline.CharForward 28 | KeyForwardDisplay = "→" 29 | ) 30 | -------------------------------------------------------------------------------- /promptui.go: -------------------------------------------------------------------------------- 1 | // Package promptui is a library providing a simple interface to create command-line prompts for go. 2 | // It can be easily integrated into spf13/cobra, urfave/cli or any cli go application. 3 | // 4 | // promptui has two main input modes: 5 | // 6 | // Prompt provides a single line for user input. It supports optional live validation, 7 | // confirmation and masking the input. 8 | // 9 | // Select provides a list of options to choose from. It supports pagination, search, 10 | // detailed view and custom templates. 11 | package promptui 12 | 13 | import "errors" 14 | 15 | // ErrEOF is the error returned from prompts when EOF is encountered. 16 | var ErrEOF = errors.New("^D") 17 | 18 | // ErrInterrupt is the error returned from prompts when an interrupt (ctrl-c) is 19 | // encountered. 20 | var ErrInterrupt = errors.New("^C") 21 | 22 | // ErrAbort is the error returned when confirm prompts are supplied "n" 23 | var ErrAbort = errors.New("") 24 | 25 | // ValidateFunc is a placeholder type for any validation functions that validates a given input. It should return 26 | // a ValidationError if the input is not valid. 27 | type ValidateFunc func(string) error 28 | -------------------------------------------------------------------------------- /example_prompt_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | // This example shows how to use the prompt validator and templates to create a stylized prompt. 9 | // The validator will make sure the value entered is a parseable float while the templates will 10 | // color the value to show validity. 11 | func ExamplePrompt() { 12 | // The validate function follows the required validator signature. 13 | validate := func(input string) error { 14 | _, err := strconv.ParseFloat(input, 64) 15 | return err 16 | } 17 | 18 | // Each template displays the data received from the prompt with some formatting. 19 | templates := &PromptTemplates{ 20 | Prompt: "{{ . }} ", 21 | Valid: "{{ . | green }} ", 22 | Invalid: "{{ . | red }} ", 23 | Success: "{{ . | bold }} ", 24 | } 25 | 26 | prompt := Prompt{ 27 | Label: "Spicy Level", 28 | Templates: templates, 29 | Validate: validate, 30 | } 31 | 32 | result, err := prompt.Run() 33 | 34 | if err != nil { 35 | fmt.Printf("Prompt failed %v\n", err) 36 | return 37 | } 38 | 39 | // The result of the prompt, if valid, is displayed in a formatted message. 40 | fmt.Printf("You answered %s\n", result) 41 | } 42 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GO111MODULE := on 2 | export PATH := ./bin:$(PATH) 3 | 4 | ci: bootstrap lint cover 5 | .PHONY: ci 6 | 7 | ################################################# 8 | # Bootstrapping for base golang package and tool deps 9 | ################################################# 10 | 11 | bootstrap: 12 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.21.0 13 | .PHONY: bootstrap 14 | 15 | mod-update: 16 | go get -u -m 17 | go mod tidy 18 | 19 | mod-tidy: 20 | go mod tidy 21 | 22 | .PHONY: $(CMD_PKGS) 23 | .PHONY: mod-update mod-tidy 24 | 25 | ################################################# 26 | # Test and linting 27 | ################################################# 28 | # Run all the linters 29 | lint: 30 | bin/golangci-lint run ./... 31 | .PHONY: lint 32 | 33 | test: 34 | CGO_ENABLED=0 go test $$(go list ./... | grep -v generated) 35 | .PHONY: test 36 | 37 | COVER_TEST_PKGS:=$(shell find . -type f -name '*_test.go' | rev | cut -d "/" -f 2- | rev | grep -v generated | sort -u) 38 | $(COVER_TEST_PKGS:=-cover): %-cover: all-cover.txt 39 | @CGO_ENABLED=0 go test -v -coverprofile=$@.out -covermode=atomic ./$* 40 | @if [ -f $@.out ]; then \ 41 | grep -v "mode: atomic" < $@.out >> all-cover.txt; \ 42 | rm $@.out; \ 43 | fi 44 | 45 | all-cover.txt: 46 | echo "mode: atomic" > all-cover.txt 47 | 48 | cover: all-cover.txt $(COVER_TEST_PKGS:=-cover) 49 | .PHONY: cover all-cover.txt 50 | -------------------------------------------------------------------------------- /example_main_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | // This is an example for the Prompt mode of promptui. In this example, a prompt is created 10 | // with a validator function that validates the given value to make sure its a number. 11 | // If successful, it will output the chosen number in a formatted message. 12 | func Example_prompt() { 13 | validate := func(input string) error { 14 | _, err := strconv.ParseFloat(input, 64) 15 | if err != nil { 16 | return errors.New("Invalid number") 17 | } 18 | return nil 19 | } 20 | 21 | prompt := Prompt{ 22 | Label: "Number", 23 | Validate: validate, 24 | } 25 | 26 | result, err := prompt.Run() 27 | 28 | if err != nil { 29 | fmt.Printf("Prompt failed %v\n", err) 30 | return 31 | } 32 | 33 | fmt.Printf("You choose %q\n", result) 34 | } 35 | 36 | // This is an example for the Select mode of promptui. In this example, a select is created with 37 | // the days of the week as its items. When an item is selected, the selected day will be displayed 38 | // in a formatted message. 39 | func Example_select() { 40 | prompt := Select{ 41 | Label: "Select Day", 42 | Items: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", 43 | "Saturday", "Sunday"}, 44 | } 45 | 46 | _, result, err := prompt.Run() 47 | 48 | if err != nil { 49 | fmt.Printf("Prompt failed %v\n", err) 50 | return 51 | } 52 | 53 | fmt.Printf("You choose %q\n", result) 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Arigato Machine Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /_examples/custom_select/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/manifoldco/promptui" 8 | ) 9 | 10 | type pepper struct { 11 | Name string 12 | HeatUnit int 13 | Peppers int 14 | } 15 | 16 | func main() { 17 | peppers := []pepper{ 18 | {Name: "Bell Pepper", HeatUnit: 0, Peppers: 0}, 19 | {Name: "Banana Pepper", HeatUnit: 100, Peppers: 1}, 20 | {Name: "Poblano", HeatUnit: 1000, Peppers: 2}, 21 | {Name: "Jalapeño", HeatUnit: 3500, Peppers: 3}, 22 | {Name: "Aleppo", HeatUnit: 10000, Peppers: 4}, 23 | {Name: "Tabasco", HeatUnit: 30000, Peppers: 5}, 24 | {Name: "Malagueta", HeatUnit: 50000, Peppers: 6}, 25 | {Name: "Habanero", HeatUnit: 100000, Peppers: 7}, 26 | {Name: "Red Savina Habanero", HeatUnit: 350000, Peppers: 8}, 27 | {Name: "Dragon’s Breath", HeatUnit: 855000, Peppers: 9}, 28 | } 29 | 30 | templates := &promptui.SelectTemplates{ 31 | Label: "{{ . }}?", 32 | Active: "\U0001F336 {{ .Name | cyan }} ({{ .HeatUnit | red }})", 33 | Inactive: " {{ .Name | cyan }} ({{ .HeatUnit | red }})", 34 | Selected: "\U0001F336 {{ .Name | red | cyan }}", 35 | Details: ` 36 | --------- Pepper ---------- 37 | {{ "Name:" | faint }} {{ .Name }} 38 | {{ "Heat Unit:" | faint }} {{ .HeatUnit }} 39 | {{ "Peppers:" | faint }} {{ .Peppers }}`, 40 | } 41 | 42 | searcher := func(input string, index int) bool { 43 | pepper := peppers[index] 44 | name := strings.Replace(strings.ToLower(pepper.Name), " ", "", -1) 45 | input = strings.Replace(strings.ToLower(input), " ", "", -1) 46 | 47 | return strings.Contains(name, input) 48 | } 49 | 50 | prompt := promptui.Select{ 51 | Label: "Spicy Level", 52 | Items: peppers, 53 | Templates: templates, 54 | Size: 4, 55 | Searcher: searcher, 56 | } 57 | 58 | i, _, err := prompt.Run() 59 | 60 | if err != nil { 61 | fmt.Printf("Prompt failed %v\n", err) 62 | return 63 | } 64 | 65 | fmt.Printf("You choose number %d: %s\n", i+1, peppers[i].Name) 66 | } 67 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Contributions are always welcome; however, please read this document in its 4 | entirety before submitting a Pull Request or Reporting a bug. 5 | 6 | ### Table of Contents 7 | 8 | - [Reporting a bug](#reporting-a-bug) 9 | - [Security disclosure](#security-disclosure) 10 | - [Creating an issue](#creating-an-issue) 11 | - [Feature requests](#feature-requests) 12 | - [Opening a pull request](#opening-a-pull-request) 13 | - [Code of Conduct](#code-of-conduct) 14 | - [License](#license) 15 | - [Contributor license agreement](#contributor-license-agreement) 16 | 17 | --------------- 18 | 19 | # Reporting a Bug 20 | 21 | Think you've found a bug? Let us know! 22 | 23 | ### Security disclosure 24 | 25 | Security is a top priority for us. If you have encountered a security issue 26 | please responsibly disclose it by following our [security 27 | disclosure](../docs/security.md) document. 28 | 29 | # Creating an Issue 30 | 31 | Your issue must follow these guidelines for it to be considered: 32 | 33 | #### Before submitting 34 | 35 | - Check you’re on the latest version, we may have already fixed your bug! 36 | - [Search our issue 37 | tracker](https://github.com/manifoldco/promptui/issues/search&type=issues) 38 | for your problem, someone may have already reported it 39 | 40 | # Opening a Pull Request 41 | 42 | To contribute, [fork](https://help.github.com/articles/fork-a-repo/) 43 | `promptui`, commit your changes, and [open a pull 44 | request](https://help.github.com/articles/using-pull-requests/). 45 | 46 | Your request will be reviewed as soon as possible. You may be asked to make 47 | changes to your submission during the review process. 48 | 49 | #### Before submitting 50 | 51 | - Test your change thoroughly 52 | - you can run `make bootstrap && make` to ensure that the continuous integration 53 | build will succeed 54 | 55 | 56 | # Code of Conduct 57 | 58 | All community members are expected to adhere to our [code of 59 | conduct](../CODE_OF_CONDUCT.md). 60 | 61 | 62 | # License 63 | 64 | Manifold's promptui is released under the [BSD 3-Clause 65 | License](../LICENSE.md). 66 | 67 | 68 | # Contributor license agreement 69 | 70 | For legal purposes all contributors must sign a [contributor license 71 | agreement](https://cla-assistant.io/manifoldco/promptui), either for an 72 | individual or corporation, before a pull request can be accepted. 73 | 74 | You will be prompted to sign the agreement by CLA Assistant (bot) when you open 75 | a Pull Request for the first time. 76 | -------------------------------------------------------------------------------- /example_select_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Any type can be given to the select's item as long as the templates properly implement the dot notation 9 | // to display it. 10 | type pepper struct { 11 | Name string 12 | HeatUnit int 13 | Peppers int 14 | } 15 | 16 | // This examples shows a complex and customized select. 17 | func ExampleSelect() { 18 | // The select will show a series of peppers stored inside a slice of structs. To display the content of the struct, 19 | // the usual dot notation is used inside the templates to select the fields and color them. 20 | peppers := []pepper{ 21 | {Name: "Bell Pepper", HeatUnit: 0, Peppers: 0}, 22 | {Name: "Banana Pepper", HeatUnit: 100, Peppers: 1}, 23 | {Name: "Poblano", HeatUnit: 1000, Peppers: 2}, 24 | {Name: "Jalapeño", HeatUnit: 3500, Peppers: 3}, 25 | {Name: "Aleppo", HeatUnit: 10000, Peppers: 4}, 26 | {Name: "Tabasco", HeatUnit: 30000, Peppers: 5}, 27 | {Name: "Malagueta", HeatUnit: 50000, Peppers: 6}, 28 | {Name: "Habanero", HeatUnit: 100000, Peppers: 7}, 29 | {Name: "Red Savina Habanero", HeatUnit: 350000, Peppers: 8}, 30 | {Name: "Dragon’s Breath", HeatUnit: 855000, Peppers: 9}, 31 | } 32 | 33 | // The Active and Selected templates set a small pepper icon next to the name colored and the heat unit for the 34 | // active template. The details template is show at the bottom of the select's list and displays the full info 35 | // for that pepper in a multi-line template. 36 | templates := &SelectTemplates{ 37 | Label: "{{ . }}?", 38 | Active: "\U0001F336 {{ .Name | cyan }} ({{ .HeatUnit | red }})", 39 | Inactive: " {{ .Name | cyan }} ({{ .HeatUnit | red }})", 40 | Selected: "\U0001F336 {{ .Name | red | cyan }}", 41 | Details: ` 42 | --------- Pepper ---------- 43 | {{ "Name:" | faint }} {{ .Name }} 44 | {{ "Heat Unit:" | faint }} {{ .HeatUnit }} 45 | {{ "Peppers:" | faint }} {{ .Peppers }}`, 46 | } 47 | 48 | // A searcher function is implemented which enabled the search mode for the select. The function follows 49 | // the required searcher signature and finds any pepper whose name contains the searched string. 50 | searcher := func(input string, index int) bool { 51 | pepper := peppers[index] 52 | name := strings.Replace(strings.ToLower(pepper.Name), " ", "", -1) 53 | input = strings.Replace(strings.ToLower(input), " ", "", -1) 54 | 55 | return strings.Contains(name, input) 56 | } 57 | 58 | prompt := Select{ 59 | Label: "Spicy Level", 60 | Items: peppers, 61 | Templates: templates, 62 | Size: 4, 63 | Searcher: searcher, 64 | } 65 | 66 | i, _, err := prompt.Run() 67 | 68 | if err != nil { 69 | fmt.Printf("Prompt failed %v\n", err) 70 | return 71 | } 72 | 73 | // The selected pepper will be displayed with its name and index in a formatted message. 74 | fmt.Printf("You choose number %d: %s\n", i+1, peppers[i].Name) 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # promptui 2 | 3 | Interactive prompt for command-line applications. 4 | 5 | We built Promptui because we wanted to make it easy and fun to explore cloud 6 | services with [manifold cli](https://github.com/manifoldco/manifold-cli). 7 | 8 | [Code of Conduct](./CODE_OF_CONDUCT.md) | 9 | [Contribution Guidelines](./.github/CONTRIBUTING.md) 10 | 11 | [![GitHub release](https://img.shields.io/github/tag/manifoldco/promptui.svg?label=latest)](https://github.com/manifoldco/promptui/releases) 12 | [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/manifoldco/promptui) 13 | [![Travis](https://img.shields.io/travis/manifoldco/promptui/master.svg)](https://travis-ci.org/manifoldco/promptui) 14 | [![Go Report Card](https://goreportcard.com/badge/github.com/manifoldco/promptui)](https://goreportcard.com/report/github.com/manifoldco/promptui) 15 | [![License](https://img.shields.io/badge/license-BSD-blue.svg)](./LICENSE.md) 16 | 17 | ## Overview 18 | 19 | ![promptui](https://media.giphy.com/media/xUNda0Ngb5qsogLsBi/giphy.gif) 20 | 21 | Promptui is a library providing a simple interface to create command-line 22 | prompts for go. It can be easily integrated into 23 | [spf13/cobra](https://github.com/spf13/cobra), 24 | [urfave/cli](https://github.com/urfave/cli) or any cli go application. 25 | 26 | Promptui has two main input modes: 27 | 28 | - `Prompt` provides a single line for user input. Prompt supports 29 | optional live validation, confirmation and masking the input. 30 | 31 | - `Select` provides a list of options to choose from. Select supports 32 | pagination, search, detailed view and custom templates. 33 | 34 | For a full list of options check [GoDoc](https://godoc.org/github.com/manifoldco/promptui). 35 | 36 | ## Basic Usage 37 | 38 | ### Prompt 39 | 40 | ```go 41 | package main 42 | 43 | import ( 44 | "errors" 45 | "fmt" 46 | "strconv" 47 | 48 | "github.com/manifoldco/promptui" 49 | ) 50 | 51 | func main() { 52 | validate := func(input string) error { 53 | _, err := strconv.ParseFloat(input, 64) 54 | if err != nil { 55 | return errors.New("Invalid number") 56 | } 57 | return nil 58 | } 59 | 60 | prompt := promptui.Prompt{ 61 | Label: "Number", 62 | Validate: validate, 63 | } 64 | 65 | result, err := prompt.Run() 66 | 67 | if err != nil { 68 | fmt.Printf("Prompt failed %v\n", err) 69 | return 70 | } 71 | 72 | fmt.Printf("You choose %q\n", result) 73 | } 74 | ``` 75 | 76 | ### Select 77 | 78 | ```go 79 | package main 80 | 81 | import ( 82 | "fmt" 83 | 84 | "github.com/manifoldco/promptui" 85 | ) 86 | 87 | func main() { 88 | prompt := promptui.Select{ 89 | Label: "Select Day", 90 | Items: []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", 91 | "Saturday", "Sunday"}, 92 | } 93 | 94 | _, result, err := prompt.Run() 95 | 96 | if err != nil { 97 | fmt.Printf("Prompt failed %v\n", err) 98 | return 99 | } 100 | 101 | fmt.Printf("You choose %q\n", result) 102 | } 103 | ``` 104 | 105 | ### More Examples 106 | 107 | See full list of [examples](https://github.com/manifoldco/promptui/tree/master/_examples) 108 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/). 7 | 8 | ## Unreleased 9 | 10 | ## [0.9.0] - 2021-10-30 11 | 12 | ### Fixed 13 | 14 | - Resolve license incompatibility in tabwriter 15 | 16 | 17 | ## [0.8.0] - 2020-09-28 18 | 19 | ### Added 20 | 21 | - Support ctrl-h for backspace 22 | - Allow hiding entered data after submit 23 | - Allow masking input with an empty rune to hide input length 24 | 25 | ### Fixed 26 | 27 | - Fix echo of cursor after input is finished 28 | - Better support for keycodes on Windows 29 | 30 | 31 | ## [0.7.0] - 2020-01-11 32 | 33 | ### Added 34 | 35 | - Add support for configurable Stdin/Stdout on Prompt 36 | - Add support for setting initial cursor position 37 | - Switch to golangci-lint for linting 38 | 39 | ### Removed 40 | 41 | - Removed support for Go 1.11 42 | 43 | ### Fixed 44 | 45 | - Reduce tool-based deps, hopefully fixing any install issues 46 | 47 | ## [0.6.0] - 2019-11-29 48 | 49 | ### Added 50 | 51 | - Support configurable stdin 52 | 53 | ### Fixed 54 | 55 | - Correct the dep on go-i18n 56 | 57 | ## [0.5.0] - 2019-11-29 58 | 59 | ### Added 60 | 61 | - Now building and testing on go 1.11, go 1.12, and go 1.13 62 | 63 | ### Removed 64 | 65 | - Removed support for Go versions that don't include modules. 66 | 67 | ## [0.4.0] - 2019-02-19 68 | 69 | ### Added 70 | 71 | - The text displayed when an item was successfully selected can be hidden 72 | 73 | ## [0.3.2] - 2018-11-26 74 | 75 | ### Added 76 | 77 | - Support Go modules 78 | 79 | ### Fixed 80 | 81 | - Fix typos in PromptTemplates documentation 82 | 83 | ## [0.3.1] - 2018-07-26 84 | 85 | ### Added 86 | 87 | - Improved documentation for GoDoc 88 | - Navigation keys information for Windows 89 | 90 | ### Fixed 91 | 92 | - `success` template was not properly displayed after a successful prompt. 93 | 94 | ## [0.3.0] - 2018-05-22 95 | 96 | ### Added 97 | 98 | - Background colors codes and template helpers 99 | - `AllowEdit` for prompt to prevent deletion of the default value by any key 100 | - Added `StartInSearchMode` to allow starting the prompt in search mode 101 | 102 | ### Fixed 103 | 104 | - `` key press on Windows 105 | - `juju/ansiterm` dependency 106 | - `chzyer/readline#136` new api with ReadCloser 107 | - Deleting UTF-8 characters sequence 108 | 109 | ## [0.2.1] - 2017-11-30 110 | 111 | ### Fixed 112 | 113 | - `SelectWithAdd` panicking on `.Run` due to lack of keys setup 114 | - Backspace key on Windows 115 | 116 | ## [0.2.0] - 2017-11-16 117 | 118 | ### Added 119 | 120 | - `Select` items can now be searched 121 | 122 | ## [0.1.0] - 2017-11-02 123 | 124 | ### Added 125 | 126 | - extract `promptui` from [torus](https://github.com/manifoldco/torus-cli) as a 127 | standalone lib. 128 | - `promptui.Prompt` provides a single input line to capture user information. 129 | - `promptui.Select` provides a list of options to choose from. Users can 130 | navigate through the list either one item at time or by pagination 131 | -------------------------------------------------------------------------------- /cursor_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import "testing" 4 | 5 | func TestDefinedCursors(t *testing.T) { 6 | t.Run("pipeCursor", func(t *testing.T) { 7 | p := string(pipeCursor([]rune{})) 8 | if p != "|" { 9 | t.Fatalf("%x!=%x", "|", p) 10 | } 11 | }) 12 | } 13 | 14 | func TestCursor(t *testing.T) { 15 | t.Run("empty", func(t *testing.T) { 16 | cursor := Cursor{Cursor: pipeCursor} 17 | cursor.End() 18 | f := cursor.Format() 19 | if f != "|" { 20 | t.Errorf("% x!=% x", "|", cursor.Format()) 21 | } 22 | 23 | cursor.Update("sup") 24 | if cursor.Format() != "sup|" { 25 | t.Errorf("% x!=% x", "sup|", cursor.Format()) 26 | } 27 | }) 28 | 29 | t.Run("Cursor at end, append additional", func(t *testing.T) { 30 | cursor := Cursor{input: []rune("a"), Cursor: pipeCursor} 31 | cursor.End() 32 | f := cursor.Format() 33 | if f != "a|" { 34 | t.Errorf("% x!=% x", "a|", cursor.Format()) 35 | } 36 | 37 | cursor.Update(" hi") 38 | if cursor.Format() != "a hi|" { 39 | t.Errorf("% x!=% x", "a hi!", cursor.Format()) 40 | } 41 | }) 42 | 43 | t.Run("Cursor at at end, backspace", func(t *testing.T) { 44 | cursor := Cursor{input: []rune("default"), Cursor: pipeCursor} 45 | cursor.Place(len(cursor.input)) 46 | cursor.Backspace() 47 | 48 | if cursor.Format() != "defaul|" { 49 | t.Errorf("expected defaul|; found %s", cursor.Format()) 50 | } 51 | 52 | cursor.Update(" hi") 53 | if cursor.Format() != "defaul hi|" { 54 | t.Errorf("expected 'defaul hi|'; found '%s'", cursor.Format()) 55 | } 56 | }) 57 | 58 | t.Run("Cursor at beginning, append additional", func(t *testing.T) { 59 | cursor := Cursor{input: []rune("default"), Cursor: pipeCursor} 60 | t.Log("init", cursor.String()) 61 | cursor.Backspace() 62 | if cursor.Format() != "|default" { 63 | t.Errorf("expected |default; found %s", cursor.Format()) 64 | } 65 | 66 | cursor.Update("hi ") 67 | t.Log("after add", cursor.String()) 68 | if cursor.Format() != "hi |default" { 69 | t.Errorf("expected 'hi |default'; found '%s'", cursor.Format()) 70 | } 71 | cursor.Backspace() 72 | t.Log("after backspace", cursor.String()) 73 | if cursor.Format() != "hi|default" { 74 | t.Errorf("expected 'hi|default'; found '%s'", cursor.Format()) 75 | } 76 | 77 | cursor.Backspace() 78 | t.Log("after backspace", cursor.String()) 79 | if cursor.Format() != "h|default" { 80 | t.Errorf("expected 'h|default'; found '%s'", cursor.Format()) 81 | } 82 | }) 83 | 84 | t.Run("Move", func(t *testing.T) { 85 | cursor := Cursor{input: []rune("default"), Cursor: pipeCursor} 86 | if cursor.Format() != "|default" { 87 | t.Errorf("expected |default; found %s", cursor.Format()) 88 | } 89 | cursor.Move(-1) 90 | if cursor.Format() != "|default" { 91 | t.Errorf("moved backwards from beginning |default; found %s", cursor.Format()) 92 | } 93 | 94 | cursor.Move(1) 95 | if cursor.Format() != "d|efault" { 96 | t.Errorf("expected 'd|efault'; found '%s'", cursor.Format()) 97 | } 98 | cursor.Move(10) 99 | if cursor.Format() != "default|" { 100 | t.Errorf("expected 'default|'; found '%s'", cursor.Format()) 101 | } 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, 8 | body size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual 10 | identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behaviour that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behaviour by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behaviour and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behaviour. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviours that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an 52 | appointed representative at an online or offline event. Representation of a 53 | project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at 59 | [hello@manifold.co](mailto:hello@manifold.co). All complaints will be reviewed 60 | and investigated and will result in a response that is deemed necessary and 61 | appropriate to the circumstances. The project team is obligated to maintain 62 | confidentiality with regard to the reporter of an incident. Further details of 63 | specific enforcement policies may be posted separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, 72 | available at 73 | [http://contributor-covenant.org/version/1/4](http://contributor-covenant.org/version/1/4). 74 | -------------------------------------------------------------------------------- /codes.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "text/template" 8 | ) 9 | 10 | const esc = "\033[" 11 | 12 | type attribute int 13 | 14 | // The possible state of text inside the application, either Bold, faint, italic or underline. 15 | // 16 | // These constants are called through the use of the Styler function. 17 | const ( 18 | reset attribute = iota 19 | 20 | FGBold 21 | FGFaint 22 | FGItalic 23 | FGUnderline 24 | ) 25 | 26 | // The possible colors of text inside the application. 27 | // 28 | // These constants are called through the use of the Styler function. 29 | const ( 30 | FGBlack attribute = iota + 30 31 | FGRed 32 | FGGreen 33 | FGYellow 34 | FGBlue 35 | FGMagenta 36 | FGCyan 37 | FGWhite 38 | ) 39 | 40 | // The possible background colors of text inside the application. 41 | // 42 | // These constants are called through the use of the Styler function. 43 | const ( 44 | BGBlack attribute = iota + 40 45 | BGRed 46 | BGGreen 47 | BGYellow 48 | BGBlue 49 | BGMagenta 50 | BGCyan 51 | BGWhite 52 | ) 53 | 54 | // ResetCode is the character code used to reset the terminal formatting 55 | var ResetCode = fmt.Sprintf("%s%dm", esc, reset) 56 | 57 | const ( 58 | hideCursor = esc + "?25l" 59 | showCursor = esc + "?25h" 60 | clearLine = esc + "2K" 61 | ) 62 | 63 | // FuncMap defines template helpers for the output. It can be extended as a regular map. 64 | // 65 | // The functions inside the map link the state, color and background colors strings detected in templates to a Styler 66 | // function that applies the given style using the corresponding constant. 67 | var FuncMap = template.FuncMap{ 68 | "black": Styler(FGBlack), 69 | "red": Styler(FGRed), 70 | "green": Styler(FGGreen), 71 | "yellow": Styler(FGYellow), 72 | "blue": Styler(FGBlue), 73 | "magenta": Styler(FGMagenta), 74 | "cyan": Styler(FGCyan), 75 | "white": Styler(FGWhite), 76 | "bgBlack": Styler(BGBlack), 77 | "bgRed": Styler(BGRed), 78 | "bgGreen": Styler(BGGreen), 79 | "bgYellow": Styler(BGYellow), 80 | "bgBlue": Styler(BGBlue), 81 | "bgMagenta": Styler(BGMagenta), 82 | "bgCyan": Styler(BGCyan), 83 | "bgWhite": Styler(BGWhite), 84 | "bold": Styler(FGBold), 85 | "faint": Styler(FGFaint), 86 | "italic": Styler(FGItalic), 87 | "underline": Styler(FGUnderline), 88 | } 89 | 90 | func upLine(n uint) string { 91 | return movementCode(n, 'A') 92 | } 93 | 94 | func movementCode(n uint, code rune) string { 95 | return esc + strconv.FormatUint(uint64(n), 10) + string(code) 96 | } 97 | 98 | // Styler is a function that accepts multiple possible styling transforms from the state, 99 | // color and background colors constants and transforms them into a templated string 100 | // to apply those styles in the CLI. 101 | // 102 | // The returned styling function accepts a string that will be extended with 103 | // the wrapping function's styling attributes. 104 | func Styler(attrs ...attribute) func(interface{}) string { 105 | attrstrs := make([]string, len(attrs)) 106 | for i, v := range attrs { 107 | attrstrs[i] = strconv.Itoa(int(v)) 108 | } 109 | 110 | seq := strings.Join(attrstrs, ";") 111 | 112 | return func(v interface{}) string { 113 | end := "" 114 | s, ok := v.(string) 115 | if !ok || !strings.HasSuffix(s, ResetCode) { 116 | end = ResetCode 117 | } 118 | return fmt.Sprintf("%s%sm%v%s", esc, seq, v, end) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /screenbuf/screenbuf_test.go: -------------------------------------------------------------------------------- 1 | package screenbuf 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestScreen(t *testing.T) { 9 | // overwrite regular movement codes for easier visualization 10 | clearLine = []byte("\\c") 11 | moveUp = []byte("\\u") 12 | moveDown = []byte("\\d") 13 | 14 | var buf bytes.Buffer 15 | s := New(&buf) 16 | 17 | tcs := []struct { 18 | scenario string 19 | lines []string 20 | expect string 21 | cursor int 22 | height int 23 | flush bool 24 | reset bool 25 | clear bool 26 | }{ 27 | { 28 | scenario: "initial write", 29 | lines: []string{"Line One"}, 30 | expect: "\\cLine One\n", 31 | cursor: 1, 32 | height: 1, 33 | }, 34 | { 35 | scenario: "write of with same number of lines", 36 | lines: []string{"Line One"}, 37 | expect: "\\u\\cLine One\\d", 38 | cursor: 1, 39 | height: 1, 40 | }, 41 | { 42 | scenario: "write of with more lines", 43 | lines: []string{"Line One", "Line Two"}, 44 | expect: "\\u\\cLine One\\d\\cLine Two\n", 45 | cursor: 2, 46 | height: 2, 47 | }, 48 | { 49 | scenario: "write of with fewer lines", 50 | lines: []string{"line One"}, 51 | expect: "\\u\\u\\cline One\\d\\c\\d", 52 | cursor: 1, 53 | height: 2, 54 | }, 55 | { 56 | scenario: "write of way more lines", 57 | lines: []string{"line one", "line two", "line three", "line four", "line five"}, 58 | expect: "\\u\\u\\cline one\\d\\cline two\\d\\cline three\n\\cline four\n\\cline five\n", 59 | cursor: 5, 60 | height: 5, 61 | }, 62 | { 63 | scenario: "write of way less lines", 64 | lines: []string{"line one", "line two"}, 65 | expect: "\\u\\u\\u\\u\\u\\cline one\\d\\cline two\\d\\c\\d\\c\\d\\c\\d", 66 | cursor: 2, 67 | height: 5, 68 | }, 69 | { 70 | scenario: "write of way more lines", 71 | lines: []string{"line one", "line two", "line three", "line four", "line five"}, 72 | expect: "\\u\\u\\u\\u\\u\\cline one\\d\\cline two\\d\\cline three\\d\\cline four\\d\\cline five\\d", 73 | cursor: 5, 74 | height: 5, 75 | }, 76 | { 77 | scenario: "reset and write", 78 | lines: []string{"line one", "line two"}, 79 | expect: "\\u\\c\\u\\c\\u\\c\\u\\c\\u\\c\\cline one\n\\cline two\n", 80 | cursor: 2, 81 | height: 2, 82 | reset: true, 83 | }, 84 | { 85 | scenario: "clear all previous lines", 86 | lines: []string{"line one", "line two"}, 87 | expect: "\\u\\u\\cline one\\d\\cline two\\d\\u\\c\\u\\c", 88 | cursor: 0, 89 | height: 0, 90 | clear: true, 91 | }, 92 | } 93 | 94 | for _, tc := range tcs { 95 | t.Run(tc.scenario, func(t *testing.T) { 96 | buf.Reset() 97 | if tc.reset { 98 | s.Reset() 99 | } 100 | 101 | for _, line := range tc.lines { 102 | _, err := s.WriteString(line) 103 | if err != nil { 104 | t.Fatalf("expected no error, got %v", err) 105 | } 106 | } 107 | 108 | if tc.clear { 109 | if err := s.Clear(); err != nil { 110 | t.Errorf("expected no error, got %d", err) 111 | } 112 | } 113 | 114 | if tc.cursor != s.cursor { 115 | t.Errorf("expected cursor %d, got %d", tc.cursor, s.cursor) 116 | } 117 | 118 | err := s.Flush() 119 | if err != nil { 120 | t.Fatalf("expected no error, got %v", err) 121 | } 122 | 123 | got := buf.String() 124 | 125 | if tc.expect != got { 126 | t.Errorf("expected %q, got %q", tc.expect, got) 127 | } 128 | 129 | if tc.height != s.height { 130 | t.Errorf("expected height %d, got %d", tc.height, s.height) 131 | } 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /screenbuf/screenbuf.go: -------------------------------------------------------------------------------- 1 | package screenbuf 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | const esc = "\033[" 10 | 11 | var ( 12 | clearLine = []byte(esc + "2K\r") 13 | moveUp = []byte(esc + "1A") 14 | moveDown = []byte(esc + "1B") 15 | ) 16 | 17 | // ScreenBuf is a convenient way to write to terminal screens. It creates, 18 | // clears and, moves up or down lines as needed to write the output to the 19 | // terminal using ANSI escape codes. 20 | type ScreenBuf struct { 21 | w io.Writer 22 | buf *bytes.Buffer 23 | reset bool 24 | cursor int 25 | height int 26 | } 27 | 28 | // New creates and initializes a new ScreenBuf. 29 | func New(w io.Writer) *ScreenBuf { 30 | return &ScreenBuf{buf: &bytes.Buffer{}, w: w} 31 | } 32 | 33 | // Reset truncates the underlining buffer and marks all its previous lines to be 34 | // cleared during the next Write. 35 | func (s *ScreenBuf) Reset() { 36 | s.buf.Reset() 37 | s.reset = true 38 | } 39 | 40 | // Clear clears all previous lines and the output starts from the top. 41 | func (s *ScreenBuf) Clear() error { 42 | for i := 0; i < s.height; i++ { 43 | _, err := s.buf.Write(moveUp) 44 | if err != nil { 45 | return err 46 | } 47 | _, err = s.buf.Write(clearLine) 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | s.cursor = 0 53 | s.height = 0 54 | s.reset = false 55 | return nil 56 | } 57 | 58 | // Write writes a single line to the underlining buffer. If the ScreenBuf was 59 | // previously reset, all previous lines are cleared and the output starts from 60 | // the top. Lines with \r or \n will cause an error since they can interfere with the 61 | // terminal ability to move between lines. 62 | func (s *ScreenBuf) Write(b []byte) (int, error) { 63 | if bytes.ContainsAny(b, "\r\n") { 64 | return 0, fmt.Errorf("%q should not contain either \\r or \\n", b) 65 | } 66 | 67 | if s.reset { 68 | if err := s.Clear(); err != nil { 69 | return 0, err 70 | } 71 | } 72 | 73 | switch { 74 | case s.cursor == s.height: 75 | n, err := s.buf.Write(clearLine) 76 | if err != nil { 77 | return n, err 78 | } 79 | 80 | n, err = s.buf.Write(b) 81 | if err != nil { 82 | return n, err 83 | } 84 | 85 | _, err = s.buf.Write([]byte("\n")) 86 | if err != nil { 87 | return n, err 88 | } 89 | 90 | s.height++ 91 | s.cursor++ 92 | return n, nil 93 | case s.cursor < s.height: 94 | n, err := s.buf.Write(clearLine) 95 | if err != nil { 96 | return n, err 97 | } 98 | n, err = s.buf.Write(b) 99 | if err != nil { 100 | return n, err 101 | } 102 | n, err = s.buf.Write(moveDown) 103 | if err != nil { 104 | return n, err 105 | } 106 | s.cursor++ 107 | return n, nil 108 | default: 109 | return 0, fmt.Errorf("Invalid write cursor position (%d) exceeded line height: %d", s.cursor, s.height) 110 | } 111 | } 112 | 113 | // Flush writes any buffered data to the underlying io.Writer, ensuring that any pending data is displayed. 114 | func (s *ScreenBuf) Flush() error { 115 | for i := s.cursor; i < s.height; i++ { 116 | if i < s.height { 117 | _, err := s.buf.Write(clearLine) 118 | if err != nil { 119 | return err 120 | } 121 | } 122 | _, err := s.buf.Write(moveDown) 123 | if err != nil { 124 | return err 125 | } 126 | } 127 | 128 | _, err := s.buf.WriteTo(s.w) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | s.buf.Reset() 134 | 135 | for i := 0; i < s.height; i++ { 136 | _, err := s.buf.Write(moveUp) 137 | if err != nil { 138 | return err 139 | } 140 | } 141 | 142 | s.cursor = 0 143 | 144 | return nil 145 | } 146 | 147 | // WriteString is a convenient function to write a new line passing a string. 148 | // Check ScreenBuf.Write() for a detailed explanation of the function behaviour. 149 | func (s *ScreenBuf) WriteString(str string) (int, error) { 150 | return s.Write([]byte(str)) 151 | } 152 | -------------------------------------------------------------------------------- /list/list_test.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestListNew(t *testing.T) { 10 | t.Run("when items a slice nil", func(t *testing.T) { 11 | _, err := New([]int{1, 2, 3}, 3) 12 | if err != nil { 13 | t.Errorf("Expected no errors, error %v", err) 14 | } 15 | }) 16 | 17 | t.Run("when items is nil", func(t *testing.T) { 18 | _, err := New(nil, 3) 19 | if err == nil { 20 | t.Errorf("Expected error got none") 21 | } 22 | }) 23 | 24 | t.Run("when items is not a slice", func(t *testing.T) { 25 | _, err := New("1,2,3", 3) 26 | if err == nil { 27 | t.Errorf("Expected error got none") 28 | } 29 | }) 30 | } 31 | 32 | func TestListMovement(t *testing.T) { 33 | letters := []rune{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'} 34 | 35 | l, err := New(letters, 4) 36 | if err != nil { 37 | t.Fatalf("Expected no error, got %v", err) 38 | } 39 | 40 | tcs := []struct { 41 | expect []rune 42 | move string 43 | selected rune 44 | }{ 45 | {move: "next", selected: 'b', expect: []rune{'a', 'b', 'c', 'd'}}, 46 | {move: "prev", selected: 'a', expect: []rune{'a', 'b', 'c', 'd'}}, 47 | {move: "prev", selected: 'a', expect: []rune{'a', 'b', 'c', 'd'}}, 48 | {move: "next", selected: 'b', expect: []rune{'a', 'b', 'c', 'd'}}, 49 | {move: "next", selected: 'c', expect: []rune{'a', 'b', 'c', 'd'}}, 50 | {move: "next", selected: 'd', expect: []rune{'a', 'b', 'c', 'd'}}, 51 | {move: "next", selected: 'e', expect: []rune{'b', 'c', 'd', 'e'}}, 52 | {move: "prev", selected: 'd', expect: []rune{'b', 'c', 'd', 'e'}}, 53 | {move: "up", selected: 'a', expect: []rune{'a', 'b', 'c', 'd'}}, 54 | {move: "up", selected: 'a', expect: []rune{'a', 'b', 'c', 'd'}}, 55 | {move: "down", selected: 'e', expect: []rune{'e', 'f', 'g', 'h'}}, 56 | {move: "down", selected: 'g', expect: []rune{'g', 'h', 'i', 'j'}}, 57 | {move: "down", selected: 'j', expect: []rune{'g', 'h', 'i', 'j'}}, 58 | } 59 | 60 | for _, tc := range tcs { 61 | t.Run(fmt.Sprintf("list %s", tc.move), func(t *testing.T) { 62 | switch tc.move { 63 | case "next": 64 | l.Next() 65 | case "prev": 66 | l.Prev() 67 | case "up": 68 | l.PageUp() 69 | case "down": 70 | l.PageDown() 71 | default: 72 | t.Fatalf("unknown move %q", tc.move) 73 | } 74 | 75 | list, idx := l.Items() 76 | 77 | got := castList(list) 78 | 79 | if !reflect.DeepEqual(tc.expect, got) { 80 | t.Errorf("expected %q, got %q", tc.expect, got) 81 | } 82 | 83 | selected := list[idx] 84 | 85 | if tc.selected != selected { 86 | t.Errorf("expected selected to be %q, got %q", tc.selected, selected) 87 | } 88 | }) 89 | } 90 | } 91 | 92 | func TestListPageDown(t *testing.T) { 93 | t.Run("when list has fewer items than page size", func(t *testing.T) { 94 | letters := []rune{'a', 'b'} 95 | l, err := New(letters, 4) 96 | if err != nil { 97 | t.Fatalf("Expected no error, got %v", err) 98 | } 99 | 100 | l.PageDown() 101 | list, idx := l.Items() 102 | 103 | expected := 'b' 104 | selected := list[idx] 105 | 106 | if selected != expected { 107 | t.Errorf("expected selected to be %q, got %q", expected, selected) 108 | } 109 | }) 110 | } 111 | 112 | func TestListComparion(t *testing.T) { 113 | t.Run("when item supports comparison", func(t *testing.T) { 114 | type comparable struct { 115 | Number int 116 | } 117 | 118 | structs := []comparable{ 119 | {Number: 1}, 120 | {Number: 2}, 121 | } 122 | 123 | l, err := New(structs, 4) 124 | if err != nil { 125 | t.Fatalf("Expected no error, got %v", err) 126 | } 127 | 128 | idx := l.Index() 129 | 130 | if idx != 0 { 131 | t.Errorf("expected index to be first, got %d", idx) 132 | } 133 | }) 134 | 135 | t.Run("when item doesn't support comparison", func(t *testing.T) { 136 | type uncomparable struct { 137 | Numbers []int 138 | } 139 | 140 | structs := []uncomparable{ 141 | {Numbers: []int{1}}, 142 | {Numbers: []int{2}}, 143 | } 144 | 145 | l, err := New(structs, 4) 146 | if err != nil { 147 | t.Fatalf("Expected no error, got %v", err) 148 | } 149 | 150 | idx := l.Index() 151 | 152 | if idx != 0 { 153 | t.Errorf("expected index to be first, got %d", idx) 154 | } 155 | }) 156 | } 157 | 158 | func castList(list []interface{}) []rune { 159 | result := make([]rune, len(list)) 160 | for i, l := range list { 161 | result[i] = l.(rune) 162 | } 163 | return result 164 | } 165 | -------------------------------------------------------------------------------- /select_test.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/manifoldco/promptui/screenbuf" 8 | ) 9 | 10 | func TestSelectTemplateRender(t *testing.T) { 11 | t.Run("when using default style", func(t *testing.T) { 12 | values := []string{"Zero"} 13 | s := Select{ 14 | Label: "Select Number", 15 | Items: values, 16 | } 17 | err := s.prepareTemplates() 18 | if err != nil { 19 | t.Fatalf("Unexpected error preparing templates %v", err) 20 | } 21 | 22 | result := string(render(s.Templates.label, s.Label)) 23 | exp := "\x1b[34m?\x1b[0m Select Number: " 24 | if result != exp { 25 | t.Errorf("Expected label to eq %q, got %q", exp, result) 26 | } 27 | 28 | result = string(render(s.Templates.active, values[0])) 29 | exp = "\x1b[1m▸\x1b[0m \x1b[4mZero\x1b[0m" 30 | if result != exp { 31 | t.Errorf("Expected active item to eq %q, got %q", exp, result) 32 | } 33 | 34 | result = string(render(s.Templates.inactive, values[0])) 35 | exp = " Zero" 36 | if result != exp { 37 | t.Errorf("Expected inactive item to eq %q, got %q", exp, result) 38 | } 39 | 40 | result = string(render(s.Templates.selected, values[0])) 41 | exp = "\x1b[32m\x1b[32m✔\x1b[0m \x1b[2mZero\x1b[0m" 42 | if result != exp { 43 | t.Errorf("Expected selected item to eq %q, got %q", exp, result) 44 | } 45 | }) 46 | 47 | t.Run("when using custom style", func(t *testing.T) { 48 | type pepper struct { 49 | Name string 50 | HeatUnit int 51 | Peppers int 52 | Description string 53 | } 54 | peppers := []pepper{ 55 | { 56 | Name: "Bell Pepper", 57 | HeatUnit: 0, 58 | Peppers: 1, 59 | Description: "Not very spicy!", 60 | }, 61 | } 62 | 63 | templates := &SelectTemplates{ 64 | Label: "{{ . }}?", 65 | Active: "\U0001F525 {{ .Name | bold }} ({{ .HeatUnit | red | italic }})", 66 | Inactive: " {{ .Name | bold }} ({{ .HeatUnit | red | italic }})", 67 | Selected: "\U0001F525 {{ .Name | red | bold }}", 68 | Details: `Name: {{.Name}} 69 | Peppers: {{.Peppers}} 70 | Description: {{.Description}}`, 71 | } 72 | 73 | s := Select{ 74 | Label: "Spicy Level", 75 | Items: peppers, 76 | Templates: templates, 77 | } 78 | 79 | err := s.prepareTemplates() 80 | if err != nil { 81 | t.Fatalf("Unexpected error preparing templates %v", err) 82 | } 83 | 84 | result := string(render(s.Templates.label, s.Label)) 85 | exp := "Spicy Level?" 86 | if result != exp { 87 | t.Errorf("Expected label to eq %q, got %q", exp, result) 88 | } 89 | 90 | result = string(render(s.Templates.active, peppers[0])) 91 | exp = "🔥 \x1b[1mBell Pepper\x1b[0m (\x1b[3m\x1b[31m0\x1b[0m)" 92 | if result != exp { 93 | t.Errorf("Expected active item to eq %q, got %q", exp, result) 94 | } 95 | 96 | result = string(render(s.Templates.inactive, peppers[0])) 97 | exp = " \x1b[1mBell Pepper\x1b[0m (\x1b[3m\x1b[31m0\x1b[0m)" 98 | if result != exp { 99 | t.Errorf("Expected inactive item to eq %q, got %q", exp, result) 100 | } 101 | 102 | result = string(render(s.Templates.selected, peppers[0])) 103 | exp = "🔥 \x1b[1m\x1b[31mBell Pepper\x1b[0m" 104 | if result != exp { 105 | t.Errorf("Expected selected item to eq %q, got %q", exp, result) 106 | } 107 | 108 | result = string(render(s.Templates.details, peppers[0])) 109 | exp = "Name: Bell Pepper\nPeppers: 1\nDescription: Not very spicy!" 110 | if result != exp { 111 | t.Errorf("Expected selected item to eq %q, got %q", exp, result) 112 | } 113 | }) 114 | 115 | t.Run("when a template is invalid", func(t *testing.T) { 116 | templates := &SelectTemplates{ 117 | Label: "{{ . ", 118 | } 119 | 120 | s := Select{ 121 | Label: "Spicy Level", 122 | Templates: templates, 123 | } 124 | 125 | err := s.prepareTemplates() 126 | if err == nil { 127 | t.Fatalf("Expected error got none") 128 | } 129 | }) 130 | 131 | t.Run("when a template render fails", func(t *testing.T) { 132 | templates := &SelectTemplates{ 133 | Label: "{{ .InvalidName }}", 134 | } 135 | 136 | s := Select{ 137 | Label: struct{ Name string }{Name: "Pepper"}, 138 | Items: []string{}, 139 | Templates: templates, 140 | } 141 | 142 | err := s.prepareTemplates() 143 | if err != nil { 144 | t.Fatalf("Unexpected error preparing templates %v", err) 145 | } 146 | 147 | result := string(render(s.Templates.label, s.Label)) 148 | exp := "{Pepper}" 149 | if result != exp { 150 | t.Errorf("Expected label to eq %q, got %q", exp, result) 151 | } 152 | }) 153 | } 154 | 155 | func TestClearScreen(t *testing.T) { 156 | var buf bytes.Buffer 157 | sb := screenbuf.New(&buf) 158 | 159 | sb.WriteString("test") 160 | clearScreen(sb) 161 | 162 | got := buf.String() 163 | except := "\x1b[1A\x1b[2K\r" 164 | 165 | if except != got { 166 | t.Errorf("expected %q, got %q", except, got) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // Searcher is a base function signature that is used inside select when activating the search mode. 10 | // If defined, it is called on each items of the select and should return a boolean for whether or not 11 | // the item fits the searched term. 12 | type Searcher func(input string, index int) bool 13 | 14 | // NotFound is an index returned when no item was selected. This could 15 | // happen due to a search without results. 16 | const NotFound = -1 17 | 18 | // List holds a collection of items that can be displayed with an N number of 19 | // visible items. The list can be moved up, down by one item of time or an 20 | // entire page (ie: visible size). It keeps track of the current selected item. 21 | type List struct { 22 | items []*interface{} 23 | scope []*interface{} 24 | cursor int // cursor holds the index of the current selected item 25 | size int // size is the number of visible options 26 | start int 27 | Searcher Searcher 28 | } 29 | 30 | // New creates and initializes a list of searchable items. The items attribute must be a slice type with a 31 | // size greater than 0. Error will be returned if those two conditions are not met. 32 | func New(items interface{}, size int) (*List, error) { 33 | if size < 1 { 34 | return nil, fmt.Errorf("list size %d must be greater than 0", size) 35 | } 36 | 37 | if items == nil || reflect.TypeOf(items).Kind() != reflect.Slice { 38 | return nil, fmt.Errorf("items %v is not a slice", items) 39 | } 40 | 41 | slice := reflect.ValueOf(items) 42 | values := make([]*interface{}, slice.Len()) 43 | 44 | for i := range values { 45 | item := slice.Index(i).Interface() 46 | values[i] = &item 47 | } 48 | 49 | return &List{size: size, items: values, scope: values}, nil 50 | } 51 | 52 | // Prev moves the visible list back one item. If the selected item is out of 53 | // view, the new select item becomes the last visible item. If the list is 54 | // already at the top, nothing happens. 55 | func (l *List) Prev() { 56 | if l.cursor > 0 { 57 | l.cursor-- 58 | } 59 | 60 | if l.start > l.cursor { 61 | l.start = l.cursor 62 | } 63 | } 64 | 65 | // Search allows the list to be filtered by a given term. The list must 66 | // implement the searcher function signature for this functionality to work. 67 | func (l *List) Search(term string) { 68 | term = strings.Trim(term, " ") 69 | l.cursor = 0 70 | l.start = 0 71 | l.search(term) 72 | } 73 | 74 | // CancelSearch stops the current search and returns the list to its 75 | // original order. 76 | func (l *List) CancelSearch() { 77 | l.cursor = 0 78 | l.start = 0 79 | l.scope = l.items 80 | } 81 | 82 | func (l *List) search(term string) { 83 | var scope []*interface{} 84 | 85 | for i, item := range l.items { 86 | if l.Searcher(term, i) { 87 | scope = append(scope, item) 88 | } 89 | } 90 | 91 | l.scope = scope 92 | } 93 | 94 | // Start returns the current render start position of the list. 95 | func (l *List) Start() int { 96 | return l.start 97 | } 98 | 99 | // SetStart sets the current scroll position. Values out of bounds will be 100 | // clamped. 101 | func (l *List) SetStart(i int) { 102 | if i < 0 { 103 | i = 0 104 | } 105 | if i > l.cursor { 106 | l.start = l.cursor 107 | } else { 108 | l.start = i 109 | } 110 | } 111 | 112 | // SetCursor sets the position of the cursor in the list. Values out of bounds 113 | // will be clamped. 114 | func (l *List) SetCursor(i int) { 115 | max := len(l.scope) - 1 116 | if i >= max { 117 | i = max 118 | } 119 | if i < 0 { 120 | i = 0 121 | } 122 | l.cursor = i 123 | 124 | if l.start > l.cursor { 125 | l.start = l.cursor 126 | } else if l.start+l.size <= l.cursor { 127 | l.start = l.cursor - l.size + 1 128 | } 129 | } 130 | 131 | // Next moves the visible list forward one item. If the selected item is out of 132 | // view, the new select item becomes the first visible item. If the list is 133 | // already at the bottom, nothing happens. 134 | func (l *List) Next() { 135 | max := len(l.scope) - 1 136 | 137 | if l.cursor < max { 138 | l.cursor++ 139 | } 140 | 141 | if l.start+l.size <= l.cursor { 142 | l.start = l.cursor - l.size + 1 143 | } 144 | } 145 | 146 | // PageUp moves the visible list backward by x items. Where x is the size of the 147 | // visible items on the list. The selected item becomes the first visible item. 148 | // If the list is already at the bottom, the selected item becomes the last 149 | // visible item. 150 | func (l *List) PageUp() { 151 | start := l.start - l.size 152 | if start < 0 { 153 | l.start = 0 154 | } else { 155 | l.start = start 156 | } 157 | 158 | cursor := l.start 159 | 160 | if cursor < l.cursor { 161 | l.cursor = cursor 162 | } 163 | } 164 | 165 | // PageDown moves the visible list forward by x items. Where x is the size of 166 | // the visible items on the list. The selected item becomes the first visible 167 | // item. 168 | func (l *List) PageDown() { 169 | start := l.start + l.size 170 | max := len(l.scope) - l.size 171 | 172 | switch { 173 | case len(l.scope) < l.size: 174 | l.start = 0 175 | case start > max: 176 | l.start = max 177 | default: 178 | l.start = start 179 | } 180 | 181 | cursor := l.start 182 | 183 | if cursor == l.cursor { 184 | l.cursor = len(l.scope) - 1 185 | } else if cursor > l.cursor { 186 | l.cursor = cursor 187 | } 188 | } 189 | 190 | // CanPageDown returns whether a list can still PageDown(). 191 | func (l *List) CanPageDown() bool { 192 | max := len(l.scope) 193 | return l.start+l.size < max 194 | } 195 | 196 | // CanPageUp returns whether a list can still PageUp(). 197 | func (l *List) CanPageUp() bool { 198 | return l.start > 0 199 | } 200 | 201 | // Index returns the index of the item currently selected inside the searched list. If no item is selected, 202 | // the NotFound (-1) index is returned. 203 | func (l *List) Index() int { 204 | selected := l.scope[l.cursor] 205 | 206 | for i, item := range l.items { 207 | if item == selected { 208 | return i 209 | } 210 | } 211 | 212 | return NotFound 213 | } 214 | 215 | // Items returns a slice equal to the size of the list with the current visible 216 | // items and the index of the active item in this list. 217 | func (l *List) Items() ([]interface{}, int) { 218 | var result []interface{} 219 | max := len(l.scope) 220 | end := l.start + l.size 221 | 222 | if end > max { 223 | end = max 224 | } 225 | 226 | active := NotFound 227 | 228 | for i, j := l.start, 0; i < end; i, j = i+1, j+1 { 229 | if l.cursor == i { 230 | active = j 231 | } 232 | 233 | result = append(result, *l.scope[i]) 234 | } 235 | 236 | return result, active 237 | } 238 | -------------------------------------------------------------------------------- /cursor.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Pointer is A specific type that translates a given set of runes into a given 9 | // set of runes pointed at by the cursor. 10 | type Pointer func(to []rune) []rune 11 | 12 | func defaultCursor(ignored []rune) []rune { 13 | return []rune("\u2588") 14 | } 15 | 16 | func blockCursor(input []rune) []rune { 17 | return []rune(fmt.Sprintf("\\e[7m%s\\e[0m", string(input))) 18 | } 19 | 20 | func pipeCursor(input []rune) []rune { 21 | marker := []rune("|") 22 | out := []rune{} 23 | out = append(out, marker...) 24 | out = append(out, input...) 25 | return out 26 | } 27 | 28 | var ( 29 | // DefaultCursor is a big square block character. Obscures whatever was 30 | // input. 31 | DefaultCursor Pointer = defaultCursor 32 | // BlockCursor is a cursor which highlights a character by inverting colors 33 | // on it. 34 | BlockCursor Pointer = blockCursor 35 | // PipeCursor is a pipe character "|" which appears before the input 36 | // character. 37 | PipeCursor Pointer = pipeCursor 38 | ) 39 | 40 | // Cursor tracks the state associated with the movable cursor 41 | // The strategy is to keep the prompt, input pristine except for requested 42 | // modifications. The insertion of the cursor happens during a `format` call 43 | // and we read in new input via an `Update` call 44 | type Cursor struct { 45 | // shows where the user inserts/updates text 46 | Cursor Pointer 47 | // what the user entered, and what we will echo back to them, after 48 | // insertion of the cursor and prefixing with the prompt 49 | input []rune 50 | // Put the cursor before this slice 51 | Position int 52 | erase bool 53 | } 54 | 55 | // NewCursor create a new cursor, with the DefaultCursor, the specified input, 56 | // and position at the end of the specified starting input. 57 | func NewCursor(startinginput string, pointer Pointer, eraseDefault bool) Cursor { 58 | if pointer == nil { 59 | pointer = defaultCursor 60 | } 61 | cur := Cursor{Cursor: pointer, Position: len(startinginput), input: []rune(startinginput), erase: eraseDefault} 62 | if eraseDefault { 63 | cur.Start() 64 | } else { 65 | cur.End() 66 | } 67 | return cur 68 | } 69 | 70 | func (c *Cursor) String() string { 71 | return fmt.Sprintf( 72 | "Cursor: %s, input %s, Position %d", 73 | string(c.Cursor([]rune(""))), string(c.input), c.Position) 74 | } 75 | 76 | // End is a convenience for c.Place(len(c.input)) so you don't have to know how I 77 | // indexed. 78 | func (c *Cursor) End() { 79 | c.Place(len(c.input)) 80 | } 81 | 82 | // Start is convenience for c.Place(0) so you don't have to know how I 83 | // indexed. 84 | func (c *Cursor) Start() { 85 | c.Place(0) 86 | } 87 | 88 | // ensures we are in bounds. 89 | func (c *Cursor) correctPosition() { 90 | if c.Position > len(c.input) { 91 | c.Position = len(c.input) 92 | } 93 | 94 | if c.Position < 0 { 95 | c.Position = 0 96 | } 97 | } 98 | 99 | // insert the cursor rune array into r before the provided index 100 | func format(a []rune, c *Cursor) string { 101 | i := c.Position 102 | var b []rune 103 | 104 | out := make([]rune, 0) 105 | if i < len(a) { 106 | b = c.Cursor(a[i : i+1]) 107 | out = append(out, a[:i]...) // does not include i 108 | out = append(out, b...) // add the cursor 109 | out = append(out, a[i+1:]...) // add the rest after i 110 | } else { 111 | b = c.Cursor([]rune{}) 112 | out = append(out, a...) 113 | out = append(out, b...) 114 | } 115 | return string(out) 116 | } 117 | 118 | // Format renders the input with the Cursor appropriately positioned. 119 | func (c *Cursor) Format() string { 120 | r := c.input 121 | // insert the cursor 122 | return format(r, c) 123 | } 124 | 125 | // FormatMask replaces all input runes with the mask rune. 126 | func (c *Cursor) FormatMask(mask rune) string { 127 | if mask == ' ' { 128 | return format([]rune{}, c) 129 | } 130 | 131 | r := make([]rune, len(c.input)) 132 | for i := range r { 133 | r[i] = mask 134 | } 135 | return format(r, c) 136 | } 137 | 138 | // Update inserts newinput into the input []rune in the appropriate place. 139 | // The cursor is moved to the end of the inputed sequence. 140 | func (c *Cursor) Update(newinput string) { 141 | a := c.input 142 | b := []rune(newinput) 143 | i := c.Position 144 | a = append(a[:i], append(b, a[i:]...)...) 145 | c.input = a 146 | c.Move(len(b)) 147 | } 148 | 149 | // Get returns a copy of the input 150 | func (c *Cursor) Get() string { 151 | return string(c.input) 152 | } 153 | 154 | // GetMask returns a mask string with length equal to the input 155 | func (c *Cursor) GetMask(mask rune) string { 156 | return strings.Repeat(string(mask), len(c.input)) 157 | } 158 | 159 | // Replace replaces the previous input with whatever is specified, and moves the 160 | // cursor to the end position 161 | func (c *Cursor) Replace(input string) { 162 | c.input = []rune(input) 163 | c.End() 164 | } 165 | 166 | // Place moves the cursor to the absolute array index specified by position 167 | func (c *Cursor) Place(position int) { 168 | c.Position = position 169 | c.correctPosition() 170 | } 171 | 172 | // Move moves the cursor over in relative terms, by shift indices. 173 | func (c *Cursor) Move(shift int) { 174 | // delete the current cursor 175 | c.Position = c.Position + shift 176 | c.correctPosition() 177 | } 178 | 179 | // Backspace removes the rune that precedes the cursor 180 | // 181 | // It handles being at the beginning or end of the row, and moves the cursor to 182 | // the appropriate position. 183 | func (c *Cursor) Backspace() { 184 | a := c.input 185 | i := c.Position 186 | if i == 0 { 187 | // Shrug 188 | return 189 | } 190 | if i == len(a) { 191 | c.input = a[:i-1] 192 | } else { 193 | c.input = append(a[:i-1], a[i:]...) 194 | } 195 | // now it's pointing to the i+1th element 196 | c.Move(-1) 197 | } 198 | 199 | // Listen is a readline Listener that updates internal cursor state appropriately. 200 | func (c *Cursor) Listen(line []rune, pos int, key rune) ([]rune, int, bool) { 201 | if line != nil { 202 | // no matter what, update our internal representation. 203 | c.Update(string(line)) 204 | } 205 | 206 | switch key { 207 | case 0: // empty 208 | case KeyEnter: 209 | return []rune(c.Get()), c.Position, false 210 | case KeyBackspace, KeyCtrlH: 211 | if c.erase { 212 | c.erase = false 213 | c.Replace("") 214 | } 215 | c.Backspace() 216 | case KeyForward: 217 | // the user wants to edit the default, despite how we set it up. Let 218 | // them. 219 | c.erase = false 220 | c.Move(1) 221 | case KeyBackward: 222 | c.Move(-1) 223 | default: 224 | if c.erase { 225 | c.erase = false 226 | c.Replace("") 227 | c.Update(string(key)) 228 | } 229 | } 230 | 231 | return []rune(c.Get()), c.Position, true 232 | } 233 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "text/template" 8 | 9 | "github.com/chzyer/readline" 10 | "github.com/manifoldco/promptui/screenbuf" 11 | ) 12 | 13 | // Prompt represents a single line text field input with options for validation and input masks. 14 | type Prompt struct { 15 | // Label is the value displayed on the command line prompt. 16 | // 17 | // The value for Label can be a simple string or a struct that will need to be accessed by dot notation 18 | // inside the templates. For example, `{{ .Name }}` will display the name property of a struct. 19 | Label interface{} 20 | 21 | // Default is the initial value for the prompt. This value will be displayed next to the prompt's label 22 | // and the user will be able to view or change it depending on the options. 23 | Default string 24 | 25 | // AllowEdit lets the user edit the default value. If false, any key press 26 | // other than automatically clears the default value. 27 | AllowEdit bool 28 | 29 | // Validate is an optional function that fill be used against the entered value in the prompt to validate it. 30 | Validate ValidateFunc 31 | 32 | // Mask is an optional rune that sets which character to display instead of the entered characters. This 33 | // allows hiding private information like passwords. 34 | Mask rune 35 | 36 | // HideEntered sets whether to hide the text after the user has pressed enter. 37 | HideEntered bool 38 | 39 | // Templates can be used to customize the prompt output. If nil is passed, the 40 | // default templates are used. See the PromptTemplates docs for more info. 41 | Templates *PromptTemplates 42 | 43 | // IsConfirm makes the prompt ask for a yes or no ([Y/N]) question rather than request an input. When set, 44 | // most properties related to input will be ignored. 45 | IsConfirm bool 46 | 47 | // IsVimMode enables vi-like movements (hjkl) and editing. 48 | IsVimMode bool 49 | 50 | // the Pointer defines how to render the cursor. 51 | Pointer Pointer 52 | 53 | Stdin io.ReadCloser 54 | Stdout io.WriteCloser 55 | } 56 | 57 | // PromptTemplates allow a prompt to be customized following stdlib 58 | // text/template syntax. Custom state, colors and background color are available for use inside 59 | // the templates and are documented inside the Variable section of the docs. 60 | // 61 | // Examples 62 | // 63 | // text/templates use a special notation to display programmable content. Using the double bracket notation, 64 | // the value can be printed with specific helper functions. For example 65 | // 66 | // This displays the value given to the template as pure, unstylized text. 67 | // '{{ . }}' 68 | // 69 | // This displays the value colored in cyan 70 | // '{{ . | cyan }}' 71 | // 72 | // This displays the value colored in red with a cyan background-color 73 | // '{{ . | red | cyan }}' 74 | // 75 | // See the doc of text/template for more info: https://golang.org/pkg/text/template/ 76 | type PromptTemplates struct { 77 | // Prompt is a text/template for the prompt label displayed on the left side of the prompt. 78 | Prompt string 79 | 80 | // Prompt is a text/template for the prompt label when IsConfirm is set as true. 81 | Confirm string 82 | 83 | // Valid is a text/template for the prompt label when the value entered is valid. 84 | Valid string 85 | 86 | // Invalid is a text/template for the prompt label when the value entered is invalid. 87 | Invalid string 88 | 89 | // Success is a text/template for the prompt label when the user has pressed entered and the value has been 90 | // deemed valid by the validation function. The label will keep using this template even when the prompt ends 91 | // inside the console. 92 | Success string 93 | 94 | // Prompt is a text/template for the prompt label when the value is invalid due to an error triggered by 95 | // the prompt's validation function. 96 | ValidationError string 97 | 98 | // FuncMap is a map of helper functions that can be used inside of templates according to the text/template 99 | // documentation. 100 | // 101 | // By default, FuncMap contains the color functions used to color the text in templates. If FuncMap 102 | // is overridden, the colors functions must be added in the override from promptui.FuncMap to work. 103 | FuncMap template.FuncMap 104 | 105 | prompt *template.Template 106 | valid *template.Template 107 | invalid *template.Template 108 | validation *template.Template 109 | success *template.Template 110 | } 111 | 112 | // Run executes the prompt. Its displays the label and default value if any, asking the user to enter a value. 113 | // Run will keep the prompt alive until it has been canceled from the command prompt or it has received a valid 114 | // value. It will return the value and an error if any occurred during the prompt's execution. 115 | func (p *Prompt) Run() (string, error) { 116 | var err error 117 | 118 | err = p.prepareTemplates() 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | c := &readline.Config{ 124 | Stdin: p.Stdin, 125 | Stdout: p.Stdout, 126 | EnableMask: p.Mask != 0, 127 | MaskRune: p.Mask, 128 | HistoryLimit: -1, 129 | VimMode: p.IsVimMode, 130 | UniqueEditLine: true, 131 | } 132 | 133 | err = c.Init() 134 | if err != nil { 135 | return "", err 136 | } 137 | 138 | rl, err := readline.NewEx(c) 139 | if err != nil { 140 | return "", err 141 | } 142 | // we're taking over the cursor, so stop showing it. 143 | rl.Write([]byte(hideCursor)) 144 | sb := screenbuf.New(rl) 145 | 146 | validFn := func(x string) error { 147 | return nil 148 | } 149 | if p.Validate != nil { 150 | validFn = p.Validate 151 | } 152 | 153 | var inputErr error 154 | input := p.Default 155 | if p.IsConfirm { 156 | input = "" 157 | } 158 | eraseDefault := input != "" && !p.AllowEdit 159 | cur := NewCursor(input, p.Pointer, eraseDefault) 160 | 161 | listen := func(input []rune, pos int, key rune) ([]rune, int, bool) { 162 | _, _, keepOn := cur.Listen(input, pos, key) 163 | err := validFn(cur.Get()) 164 | var prompt []byte 165 | 166 | if err != nil { 167 | prompt = render(p.Templates.invalid, p.Label) 168 | } else { 169 | prompt = render(p.Templates.valid, p.Label) 170 | if p.IsConfirm { 171 | prompt = render(p.Templates.prompt, p.Label) 172 | } 173 | } 174 | 175 | echo := cur.Format() 176 | if p.Mask != 0 { 177 | echo = cur.FormatMask(p.Mask) 178 | } 179 | 180 | prompt = append(prompt, []byte(echo)...) 181 | sb.Reset() 182 | sb.Write(prompt) 183 | if inputErr != nil { 184 | validation := render(p.Templates.validation, inputErr) 185 | sb.Write(validation) 186 | inputErr = nil 187 | } 188 | sb.Flush() 189 | return nil, 0, keepOn 190 | } 191 | 192 | c.SetListener(listen) 193 | 194 | for { 195 | _, err = rl.Readline() 196 | inputErr = validFn(cur.Get()) 197 | if inputErr == nil { 198 | break 199 | } 200 | 201 | if err != nil { 202 | break 203 | } 204 | } 205 | 206 | if err != nil { 207 | switch err { 208 | case readline.ErrInterrupt: 209 | err = ErrInterrupt 210 | case io.EOF: 211 | err = ErrEOF 212 | } 213 | if err.Error() == "Interrupt" { 214 | err = ErrInterrupt 215 | } 216 | sb.Reset() 217 | sb.WriteString("") 218 | sb.Flush() 219 | rl.Write([]byte(showCursor)) 220 | rl.Close() 221 | return "", err 222 | } 223 | 224 | echo := cur.Get() 225 | if p.Mask != 0 { 226 | echo = cur.GetMask(p.Mask) 227 | } 228 | 229 | prompt := render(p.Templates.success, p.Label) 230 | prompt = append(prompt, []byte(echo)...) 231 | 232 | if p.IsConfirm { 233 | lowerDefault := strings.ToLower(p.Default) 234 | if strings.ToLower(cur.Get()) != "y" && (lowerDefault != "y" || (lowerDefault == "y" && cur.Get() != "")) { 235 | prompt = render(p.Templates.invalid, p.Label) 236 | err = ErrAbort 237 | } 238 | } 239 | 240 | if p.HideEntered { 241 | clearScreen(sb) 242 | } else { 243 | sb.Reset() 244 | sb.Write(prompt) 245 | sb.Flush() 246 | } 247 | 248 | rl.Write([]byte(showCursor)) 249 | rl.Close() 250 | 251 | return cur.Get(), err 252 | } 253 | 254 | func (p *Prompt) prepareTemplates() error { 255 | tpls := p.Templates 256 | if tpls == nil { 257 | tpls = &PromptTemplates{} 258 | } 259 | 260 | if tpls.FuncMap == nil { 261 | tpls.FuncMap = FuncMap 262 | } 263 | 264 | bold := Styler(FGBold) 265 | 266 | if p.IsConfirm { 267 | if tpls.Confirm == "" { 268 | confirm := "y/N" 269 | if strings.ToLower(p.Default) == "y" { 270 | confirm = "Y/n" 271 | } 272 | tpls.Confirm = fmt.Sprintf(`{{ "%s" | bold }} {{ . | bold }}? {{ "[%s]" | faint }} `, IconInitial, confirm) 273 | } 274 | 275 | tpl, err := template.New("").Funcs(tpls.FuncMap).Parse(tpls.Confirm) 276 | if err != nil { 277 | return err 278 | } 279 | 280 | tpls.prompt = tpl 281 | } else { 282 | if tpls.Prompt == "" { 283 | tpls.Prompt = fmt.Sprintf("%s {{ . | bold }}%s ", bold(IconInitial), bold(":")) 284 | } 285 | 286 | tpl, err := template.New("").Funcs(tpls.FuncMap).Parse(tpls.Prompt) 287 | if err != nil { 288 | return err 289 | } 290 | 291 | tpls.prompt = tpl 292 | } 293 | 294 | if tpls.Valid == "" { 295 | tpls.Valid = fmt.Sprintf("%s {{ . | bold }}%s ", bold(IconGood), bold(":")) 296 | } 297 | 298 | tpl, err := template.New("").Funcs(tpls.FuncMap).Parse(tpls.Valid) 299 | if err != nil { 300 | return err 301 | } 302 | 303 | tpls.valid = tpl 304 | 305 | if tpls.Invalid == "" { 306 | tpls.Invalid = fmt.Sprintf("%s {{ . | bold }}%s ", bold(IconBad), bold(":")) 307 | } 308 | 309 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Invalid) 310 | if err != nil { 311 | return err 312 | } 313 | 314 | tpls.invalid = tpl 315 | 316 | if tpls.ValidationError == "" { 317 | tpls.ValidationError = `{{ ">>" | red }} {{ . | red }}` 318 | } 319 | 320 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.ValidationError) 321 | if err != nil { 322 | return err 323 | } 324 | 325 | tpls.validation = tpl 326 | 327 | if tpls.Success == "" { 328 | tpls.Success = fmt.Sprintf("{{ . | faint }}%s ", Styler(FGFaint)(":")) 329 | } 330 | 331 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Success) 332 | if err != nil { 333 | return err 334 | } 335 | 336 | tpls.success = tpl 337 | 338 | p.Templates = tpls 339 | 340 | return nil 341 | } 342 | -------------------------------------------------------------------------------- /select.go: -------------------------------------------------------------------------------- 1 | package promptui 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "text/tabwriter" 9 | "text/template" 10 | 11 | "github.com/chzyer/readline" 12 | "github.com/manifoldco/promptui/list" 13 | "github.com/manifoldco/promptui/screenbuf" 14 | ) 15 | 16 | // SelectedAdd is used internally inside SelectWithAdd when the add option is selected in select mode. 17 | // Since -1 is not a possible selected index, this ensure that add mode is always unique inside 18 | // SelectWithAdd's logic. 19 | const SelectedAdd = -1 20 | 21 | // Select represents a list of items used to enable selections, they can be used as search engines, menus 22 | // or as a list of items in a cli based prompt. 23 | type Select struct { 24 | // Label is the text displayed on top of the list to direct input. The IconInitial value "?" will be 25 | // appended automatically to the label so it does not need to be added. 26 | // 27 | // The value for Label can be a simple string or a struct that will need to be accessed by dot notation 28 | // inside the templates. For example, `{{ .Name }}` will display the name property of a struct. 29 | Label interface{} 30 | 31 | // Items are the items to display inside the list. It expect a slice of any kind of values, including strings. 32 | // 33 | // If using a slice of strings, promptui will use those strings directly into its base templates or the 34 | // provided templates. If using any other type in the slice, it will attempt to transform it into a string 35 | // before giving it to its templates. Custom templates will override this behavior if using the dot notation 36 | // inside the templates. 37 | // 38 | // For example, `{{ .Name }}` will display the name property of a struct. 39 | Items interface{} 40 | 41 | // Size is the number of items that should appear on the select before scrolling is necessary. Defaults to 5. 42 | Size int 43 | 44 | // CursorPos is the initial position of the cursor. 45 | CursorPos int 46 | 47 | // IsVimMode sets whether to use vim mode when using readline in the command prompt. Look at 48 | // https://godoc.org/github.com/chzyer/readline#Config for more information on readline. 49 | IsVimMode bool 50 | 51 | // HideHelp sets whether to hide help information. 52 | HideHelp bool 53 | 54 | // HideSelected sets whether to hide the text displayed after an item is successfully selected. 55 | HideSelected bool 56 | 57 | // Templates can be used to customize the select output. If nil is passed, the 58 | // default templates are used. See the SelectTemplates docs for more info. 59 | Templates *SelectTemplates 60 | 61 | // Keys is the set of keys used in select mode to control the command line interface. See the SelectKeys docs for 62 | // more info. 63 | Keys *SelectKeys 64 | 65 | // Searcher is a function that can be implemented to refine the base searching algorithm in selects. 66 | // 67 | // Search is a function that will receive the searched term and the item's index and should return a boolean 68 | // for whether or not the terms are alike. It is unimplemented by default and search will not work unless 69 | // it is implemented. 70 | Searcher list.Searcher 71 | 72 | // StartInSearchMode sets whether or not the select mode should start in search mode or selection mode. 73 | // For search mode to work, the Search property must be implemented. 74 | StartInSearchMode bool 75 | 76 | list *list.List 77 | 78 | // A function that determines how to render the cursor 79 | Pointer Pointer 80 | 81 | Stdin io.ReadCloser 82 | Stdout io.WriteCloser 83 | } 84 | 85 | // SelectKeys defines the available keys used by select mode to enable the user to move around the list 86 | // and trigger search mode. See the Key struct docs for more information on keys. 87 | type SelectKeys struct { 88 | // Next is the key used to move to the next element inside the list. Defaults to down arrow key. 89 | Next Key 90 | 91 | // Prev is the key used to move to the previous element inside the list. Defaults to up arrow key. 92 | Prev Key 93 | 94 | // PageUp is the key used to jump back to the first element inside the list. Defaults to left arrow key. 95 | PageUp Key 96 | 97 | // PageUp is the key used to jump forward to the last element inside the list. Defaults to right arrow key. 98 | PageDown Key 99 | 100 | // Search is the key used to trigger the search mode for the list. Default to the "/" key. 101 | Search Key 102 | } 103 | 104 | // Key defines a keyboard code and a display representation for the help menu. 105 | type Key struct { 106 | // Code is a rune that will be used to compare against typed keys with readline. 107 | // Check https://github.com/chzyer/readline for a list of codes 108 | Code rune 109 | 110 | // Display is the string that will be displayed inside the help menu to help inform the user 111 | // of which key to use on his keyboard for various functions. 112 | Display string 113 | } 114 | 115 | // SelectTemplates allow a select list to be customized following stdlib 116 | // text/template syntax. Custom state, colors and background color are available for use inside 117 | // the templates and are documented inside the Variable section of the docs. 118 | // 119 | // Examples 120 | // 121 | // text/templates use a special notation to display programmable content. Using the double bracket notation, 122 | // the value can be printed with specific helper functions. For example 123 | // 124 | // This displays the value given to the template as pure, unstylized text. Structs are transformed to string 125 | // with this notation. 126 | // '{{ . }}' 127 | // 128 | // This displays the name property of the value colored in cyan 129 | // '{{ .Name | cyan }}' 130 | // 131 | // This displays the label property of value colored in red with a cyan background-color 132 | // '{{ .Label | red | cyan }}' 133 | // 134 | // See the doc of text/template for more info: https://golang.org/pkg/text/template/ 135 | // 136 | // Notes 137 | // 138 | // Setting any of these templates will remove the icons from the default templates. They must 139 | // be added back in each of their specific templates. The styles.go constants contains the default icons. 140 | type SelectTemplates struct { 141 | // Label is a text/template for the main command line label. Defaults to printing the label as it with 142 | // the IconInitial. 143 | Label string 144 | 145 | // Active is a text/template for when an item is currently active within the list. 146 | Active string 147 | 148 | // Inactive is a text/template for when an item is not currently active inside the list. This 149 | // template is used for all items unless they are active or selected. 150 | Inactive string 151 | 152 | // Selected is a text/template for when an item was successfully selected. 153 | Selected string 154 | 155 | // Details is a text/template for when an item current active to show 156 | // additional information. It can have multiple lines. 157 | // 158 | // Detail will always be displayed for the active element and thus can be used to display additional 159 | // information on the element beyond its label. 160 | // 161 | // promptui will not trim spaces and tabs will be displayed if the template is indented. 162 | Details string 163 | 164 | // Help is a text/template for displaying instructions at the top. By default 165 | // it shows keys for movement and search. 166 | Help string 167 | 168 | // FuncMap is a map of helper functions that can be used inside of templates according to the text/template 169 | // documentation. 170 | // 171 | // By default, FuncMap contains the color functions used to color the text in templates. If FuncMap 172 | // is overridden, the colors functions must be added in the override from promptui.FuncMap to work. 173 | FuncMap template.FuncMap 174 | 175 | label *template.Template 176 | active *template.Template 177 | inactive *template.Template 178 | selected *template.Template 179 | details *template.Template 180 | help *template.Template 181 | } 182 | 183 | // SearchPrompt is the prompt displayed in search mode. 184 | var SearchPrompt = "Search: " 185 | 186 | // Run executes the select list. It displays the label and the list of items, asking the user to chose any 187 | // value within to list. Run will keep the prompt alive until it has been canceled from 188 | // the command prompt or it has received a valid value. It will return the value and an error if any 189 | // occurred during the select's execution. 190 | func (s *Select) Run() (int, string, error) { 191 | return s.RunCursorAt(s.CursorPos, 0) 192 | } 193 | 194 | // RunCursorAt executes the select list, initializing the cursor to the given 195 | // position. Invalid cursor positions will be clamped to valid values. It 196 | // displays the label and the list of items, asking the user to chose any value 197 | // within to list. Run will keep the prompt alive until it has been canceled 198 | // from the command prompt or it has received a valid value. It will return 199 | // the value and an error if any occurred during the select's execution. 200 | func (s *Select) RunCursorAt(cursorPos, scroll int) (int, string, error) { 201 | if s.Size == 0 { 202 | s.Size = 5 203 | } 204 | 205 | l, err := list.New(s.Items, s.Size) 206 | if err != nil { 207 | return 0, "", err 208 | } 209 | l.Searcher = s.Searcher 210 | 211 | s.list = l 212 | 213 | s.setKeys() 214 | 215 | err = s.prepareTemplates() 216 | if err != nil { 217 | return 0, "", err 218 | } 219 | return s.innerRun(cursorPos, scroll, ' ') 220 | } 221 | 222 | func (s *Select) innerRun(cursorPos, scroll int, top rune) (int, string, error) { 223 | c := &readline.Config{ 224 | Stdin: s.Stdin, 225 | Stdout: s.Stdout, 226 | } 227 | err := c.Init() 228 | if err != nil { 229 | return 0, "", err 230 | } 231 | 232 | c.Stdin = readline.NewCancelableStdin(c.Stdin) 233 | 234 | if s.IsVimMode { 235 | c.VimMode = true 236 | } 237 | 238 | c.HistoryLimit = -1 239 | c.UniqueEditLine = true 240 | 241 | rl, err := readline.NewEx(c) 242 | if err != nil { 243 | return 0, "", err 244 | } 245 | 246 | rl.Write([]byte(hideCursor)) 247 | sb := screenbuf.New(rl) 248 | 249 | cur := NewCursor("", s.Pointer, false) 250 | 251 | canSearch := s.Searcher != nil 252 | searchMode := s.StartInSearchMode 253 | s.list.SetCursor(cursorPos) 254 | s.list.SetStart(scroll) 255 | 256 | c.SetListener(func(line []rune, pos int, key rune) ([]rune, int, bool) { 257 | switch { 258 | case key == KeyEnter: 259 | return nil, 0, true 260 | case key == s.Keys.Next.Code || (key == 'j' && !searchMode): 261 | s.list.Next() 262 | case key == s.Keys.Prev.Code || (key == 'k' && !searchMode): 263 | s.list.Prev() 264 | case key == s.Keys.Search.Code: 265 | if !canSearch { 266 | break 267 | } 268 | 269 | if searchMode { 270 | searchMode = false 271 | cur.Replace("") 272 | s.list.CancelSearch() 273 | } else { 274 | searchMode = true 275 | } 276 | case key == KeyBackspace || key == KeyCtrlH: 277 | if !canSearch || !searchMode { 278 | break 279 | } 280 | 281 | cur.Backspace() 282 | if len(cur.Get()) > 0 { 283 | s.list.Search(cur.Get()) 284 | } else { 285 | s.list.CancelSearch() 286 | } 287 | case key == s.Keys.PageUp.Code || (key == 'h' && !searchMode): 288 | s.list.PageUp() 289 | case key == s.Keys.PageDown.Code || (key == 'l' && !searchMode): 290 | s.list.PageDown() 291 | default: 292 | if canSearch && searchMode { 293 | cur.Update(string(line)) 294 | s.list.Search(cur.Get()) 295 | } 296 | } 297 | 298 | if searchMode { 299 | header := SearchPrompt + cur.Format() 300 | sb.WriteString(header) 301 | } else if !s.HideHelp { 302 | help := s.renderHelp(canSearch) 303 | sb.Write(help) 304 | } 305 | 306 | label := render(s.Templates.label, s.Label) 307 | sb.Write(label) 308 | 309 | items, idx := s.list.Items() 310 | last := len(items) - 1 311 | 312 | for i, item := range items { 313 | page := " " 314 | 315 | switch i { 316 | case 0: 317 | if s.list.CanPageUp() { 318 | page = "↑" 319 | } else { 320 | page = string(top) 321 | } 322 | case last: 323 | if s.list.CanPageDown() { 324 | page = "↓" 325 | } 326 | } 327 | 328 | output := []byte(page + " ") 329 | 330 | if i == idx { 331 | output = append(output, render(s.Templates.active, item)...) 332 | } else { 333 | output = append(output, render(s.Templates.inactive, item)...) 334 | } 335 | 336 | sb.Write(output) 337 | } 338 | 339 | if idx == list.NotFound { 340 | sb.WriteString("") 341 | sb.WriteString("No results") 342 | } else { 343 | active := items[idx] 344 | 345 | details := s.renderDetails(active) 346 | for _, d := range details { 347 | sb.Write(d) 348 | } 349 | } 350 | 351 | sb.Flush() 352 | 353 | return nil, 0, true 354 | }) 355 | 356 | for { 357 | _, err = rl.Readline() 358 | 359 | if err != nil { 360 | switch { 361 | case err == readline.ErrInterrupt, err.Error() == "Interrupt": 362 | err = ErrInterrupt 363 | case err == io.EOF: 364 | err = ErrEOF 365 | } 366 | break 367 | } 368 | 369 | _, idx := s.list.Items() 370 | if idx != list.NotFound { 371 | break 372 | } 373 | 374 | } 375 | 376 | if err != nil { 377 | if err.Error() == "Interrupt" { 378 | err = ErrInterrupt 379 | } 380 | sb.Reset() 381 | sb.WriteString("") 382 | sb.Flush() 383 | rl.Write([]byte(showCursor)) 384 | rl.Close() 385 | return 0, "", err 386 | } 387 | 388 | items, idx := s.list.Items() 389 | item := items[idx] 390 | 391 | if s.HideSelected { 392 | clearScreen(sb) 393 | } else { 394 | sb.Reset() 395 | sb.Write(render(s.Templates.selected, item)) 396 | sb.Flush() 397 | } 398 | 399 | rl.Write([]byte(showCursor)) 400 | rl.Close() 401 | 402 | return s.list.Index(), fmt.Sprintf("%v", item), err 403 | } 404 | 405 | // ScrollPosition returns the current scroll position. 406 | func (s *Select) ScrollPosition() int { 407 | return s.list.Start() 408 | } 409 | 410 | func (s *Select) prepareTemplates() error { 411 | tpls := s.Templates 412 | if tpls == nil { 413 | tpls = &SelectTemplates{} 414 | } 415 | 416 | if tpls.FuncMap == nil { 417 | tpls.FuncMap = FuncMap 418 | } 419 | 420 | if tpls.Label == "" { 421 | tpls.Label = fmt.Sprintf("%s {{.}}: ", IconInitial) 422 | } 423 | 424 | tpl, err := template.New("").Funcs(tpls.FuncMap).Parse(tpls.Label) 425 | if err != nil { 426 | return err 427 | } 428 | 429 | tpls.label = tpl 430 | 431 | if tpls.Active == "" { 432 | tpls.Active = fmt.Sprintf("%s {{ . | underline }}", IconSelect) 433 | } 434 | 435 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Active) 436 | if err != nil { 437 | return err 438 | } 439 | 440 | tpls.active = tpl 441 | 442 | if tpls.Inactive == "" { 443 | tpls.Inactive = " {{.}}" 444 | } 445 | 446 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Inactive) 447 | if err != nil { 448 | return err 449 | } 450 | 451 | tpls.inactive = tpl 452 | 453 | if tpls.Selected == "" { 454 | tpls.Selected = fmt.Sprintf(`{{ "%s" | green }} {{ . | faint }}`, IconGood) 455 | } 456 | 457 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Selected) 458 | if err != nil { 459 | return err 460 | } 461 | tpls.selected = tpl 462 | 463 | if tpls.Details != "" { 464 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Details) 465 | if err != nil { 466 | return err 467 | } 468 | 469 | tpls.details = tpl 470 | } 471 | 472 | if tpls.Help == "" { 473 | tpls.Help = fmt.Sprintf(`{{ "Use the arrow keys to navigate:" | faint }} {{ .NextKey | faint }} ` + 474 | `{{ .PrevKey | faint }} {{ .PageDownKey | faint }} {{ .PageUpKey | faint }} ` + 475 | `{{ if .Search }} {{ "and" | faint }} {{ .SearchKey | faint }} {{ "toggles search" | faint }}{{ end }}`) 476 | } 477 | 478 | tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.Help) 479 | if err != nil { 480 | return err 481 | } 482 | 483 | tpls.help = tpl 484 | 485 | s.Templates = tpls 486 | 487 | return nil 488 | } 489 | 490 | // SelectWithAdd represents a list for selecting a single item inside a list of items with the possibility to 491 | // add new items to the list. 492 | type SelectWithAdd struct { 493 | // Label is the text displayed on top of the list to direct input. The IconInitial value "?" will be 494 | // appended automatically to the label so it does not need to be added. 495 | Label string 496 | 497 | // Items are the items to display inside the list. Each item will be listed individually with the 498 | // AddLabel as the first item of the list. 499 | Items []string 500 | 501 | // AddLabel is the label used for the first item of the list that enables adding a new item. 502 | // Selecting this item in the list displays the add item prompt using promptui/prompt. 503 | AddLabel string 504 | 505 | // Validate is an optional function that fill be used against the entered value in the prompt to validate it. 506 | // If the value is valid, it is returned to the callee to be added in the list. 507 | Validate ValidateFunc 508 | 509 | // IsVimMode sets whether to use vim mode when using readline in the command prompt. Look at 510 | // https://godoc.org/github.com/chzyer/readline#Config for more information on readline. 511 | IsVimMode bool 512 | 513 | // a function that defines how to render the cursor 514 | Pointer Pointer 515 | 516 | // HideHelp sets whether to hide help information. 517 | HideHelp bool 518 | } 519 | 520 | // Run executes the select list. Its displays the label and the list of items, asking the user to chose any 521 | // value within to list or add his own. Run will keep the prompt alive until it has been canceled from 522 | // the command prompt or it has received a valid value. 523 | // 524 | // If the addLabel is selected in the list, this function will return a -1 index with the added label and no error. 525 | // Otherwise, it will return the index and the value of the selected item. In any case, if an error is triggered, it 526 | // will also return the error as its third return value. 527 | func (sa *SelectWithAdd) Run() (int, string, error) { 528 | if len(sa.Items) > 0 { 529 | newItems := append([]string{sa.AddLabel}, sa.Items...) 530 | 531 | list, err := list.New(newItems, 5) 532 | if err != nil { 533 | return 0, "", err 534 | } 535 | 536 | s := Select{ 537 | Label: sa.Label, 538 | Items: newItems, 539 | IsVimMode: sa.IsVimMode, 540 | HideHelp: sa.HideHelp, 541 | Size: 5, 542 | list: list, 543 | Pointer: sa.Pointer, 544 | } 545 | s.setKeys() 546 | 547 | err = s.prepareTemplates() 548 | if err != nil { 549 | return 0, "", err 550 | } 551 | 552 | selected, value, err := s.innerRun(1, 0, '+') 553 | if err != nil || selected != 0 { 554 | return selected - 1, value, err 555 | } 556 | 557 | // XXX run through terminal for windows 558 | os.Stdout.Write([]byte(upLine(1) + "\r" + clearLine)) 559 | } 560 | 561 | p := Prompt{ 562 | Label: sa.AddLabel, 563 | Validate: sa.Validate, 564 | IsVimMode: sa.IsVimMode, 565 | Pointer: sa.Pointer, 566 | } 567 | value, err := p.Run() 568 | return SelectedAdd, value, err 569 | } 570 | 571 | func (s *Select) setKeys() { 572 | if s.Keys != nil { 573 | return 574 | } 575 | s.Keys = &SelectKeys{ 576 | Prev: Key{Code: KeyPrev, Display: KeyPrevDisplay}, 577 | Next: Key{Code: KeyNext, Display: KeyNextDisplay}, 578 | PageUp: Key{Code: KeyBackward, Display: KeyBackwardDisplay}, 579 | PageDown: Key{Code: KeyForward, Display: KeyForwardDisplay}, 580 | Search: Key{Code: '/', Display: "/"}, 581 | } 582 | } 583 | 584 | func (s *Select) renderDetails(item interface{}) [][]byte { 585 | if s.Templates.details == nil { 586 | return nil 587 | } 588 | 589 | var buf bytes.Buffer 590 | 591 | w := tabwriter.NewWriter(&buf, 0, 0, 8, ' ', 0) 592 | 593 | err := s.Templates.details.Execute(w, item) 594 | if err != nil { 595 | fmt.Fprintf(w, "%v", item) 596 | } 597 | 598 | w.Flush() 599 | 600 | output := buf.Bytes() 601 | 602 | return bytes.Split(output, []byte("\n")) 603 | } 604 | 605 | func (s *Select) renderHelp(b bool) []byte { 606 | keys := struct { 607 | NextKey string 608 | PrevKey string 609 | PageDownKey string 610 | PageUpKey string 611 | Search bool 612 | SearchKey string 613 | }{ 614 | NextKey: s.Keys.Next.Display, 615 | PrevKey: s.Keys.Prev.Display, 616 | PageDownKey: s.Keys.PageDown.Display, 617 | PageUpKey: s.Keys.PageUp.Display, 618 | SearchKey: s.Keys.Search.Display, 619 | Search: b, 620 | } 621 | 622 | return render(s.Templates.help, keys) 623 | } 624 | 625 | func render(tpl *template.Template, data interface{}) []byte { 626 | var buf bytes.Buffer 627 | err := tpl.Execute(&buf, data) 628 | if err != nil { 629 | return []byte(fmt.Sprintf("%v", data)) 630 | } 631 | return buf.Bytes() 632 | } 633 | 634 | func clearScreen(sb *screenbuf.ScreenBuf) { 635 | sb.Reset() 636 | sb.Clear() 637 | sb.Flush() 638 | } 639 | --------------------------------------------------------------------------------