diff --git a/codes.go b/codes.go new file mode 100644 index 0000000..b14ae99 --- /dev/null +++ b/codes.go @@ -0,0 +1,73 @@ +package promptui + +import ( + "fmt" + "strconv" + "strings" +) + +const esc = "\033[" + +type attribute int + +// Forground weight/decoration attributes. +const ( + reset attribute = iota + + FGBold + FGFaint + FGItalic + FGUnderline +) + +// Forground color attributes +const ( + FGBlack attribute = iota + 30 + FGRed + FGGreen + FGYellow + FGBlue + FGMagenta + FGCyan + FGWhite +) + +// ResetCode is the character code used to reset the terminal formatting +var ResetCode = fmt.Sprintf("%s%dm", esc, reset) + +var ( + hideCursor = esc + "?25l" + showCursor = esc + "?25h" + clearLine = esc + "2K" +) + +func upLine(n uint) string { + return movementCode(n, 'A') +} + +func downLine(n uint) string { + return movementCode(n, 'B') +} + +func movementCode(n uint, code rune) string { + return esc + strconv.FormatUint(uint64(n), 10) + string(code) +} + +// Styler returns a func that applies the attributes given in the Styler call +// to the provided string. +func Styler(attrs ...attribute) func(string) string { + attrstrs := make([]string, len(attrs)) + for i, v := range attrs { + attrstrs[i] = strconv.Itoa(int(v)) + } + + seq := strings.Join(attrstrs, ";") + + return func(s string) string { + end := "" + if !strings.HasSuffix(s, ResetCode) { + end = ResetCode + } + return fmt.Sprintf("%s%sm%s%s", esc, seq, s, end) + } +} diff --git a/codes_test.go b/codes_test.go new file mode 100644 index 0000000..1abb7fd --- /dev/null +++ b/codes_test.go @@ -0,0 +1,32 @@ +package promptui + +import "testing" + +func TestStyler(t *testing.T) { + t.Run("renders a single code", func(t *testing.T) { + red := Styler(FGRed)("hi") + expected := "\033[31mhi\033[0m" + if red != expected { + t.Errorf("style did not match: %s != %s", red, expected) + } + }) + + t.Run("combines multiple codes", func(t *testing.T) { + boldRed := Styler(FGRed, FGBold)("hi") + expected := "\033[31;1mhi\033[0m" + if boldRed != expected { + t.Errorf("style did not match: %s != %s", boldRed, expected) + } + + }) + + t.Run("should not repeat reset codes for nested styles", func(t *testing.T) { + red := Styler(FGRed)("hi") + boldRed := Styler(FGBold)(red) + expected := "\033[1m\033[31mhi\033[0m" + if boldRed != expected { + t.Errorf("style did not match: %s != %s", boldRed, expected) + } + + }) +} diff --git a/prompt.go b/prompt.go new file mode 100644 index 0000000..2a8ead8 --- /dev/null +++ b/prompt.go @@ -0,0 +1,223 @@ +package promptui + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + + "github.com/chzyer/readline" +) + +// Prompt represents a single line text field input. +type Prompt struct { + Label string // Label is the value displayed on the command line prompt + + Default string // Default is the initial value to populate in the prompt + + // Validate is optional. If set, this function is used to validate the input + // after each character entry. + Validate ValidateFunc + + // If mask is set, this value is displayed instead of the actual input + // characters. + Mask rune + + IsConfirm bool + IsVimMode bool + Preamble *string + + // Indent will be placed before the prompt's state symbol + Indent string + + stdin io.Reader + stdout io.Writer +} + +// Run runs the prompt, returning the validated input. +func (p *Prompt) Run() (string, error) { + c := &readline.Config{} + err := c.Init() + if err != nil { + return "", err + } + + if p.stdin != nil { + c.Stdin = p.stdin + } + + if p.stdout != nil { + c.Stdout = p.stdout + } + + if p.Mask != 0 { + c.EnableMask = true + c.MaskRune = p.Mask + } + + if p.IsVimMode { + c.VimMode = true + } + + if p.Preamble != nil { + fmt.Println(*p.Preamble) + } + + suggestedAnswer := "" + punctuation := ":" + if p.IsConfirm { + punctuation = "?" + answers := "y/N" + if strings.ToLower(p.Default) == "y" { + answers = "Y/n" + } + suggestedAnswer = " " + faint("["+answers+"]") + p.Default = "" + } + + state := IconInitial + prompt := p.Label + punctuation + suggestedAnswer + " " + + c.Prompt = p.Indent + bold(state) + " " + bold(prompt) + c.HistoryLimit = -1 + c.UniqueEditLine = true + + firstListen := true + wroteErr := false + caughtup := true + var out string + + if p.Default != "" { + caughtup = false + out = p.Default + c.Stdin = io.MultiReader(bytes.NewBuffer([]byte(out)), os.Stdin) + } + + rl, err := readline.NewEx(c) + if err != nil { + return "", err + } + + validFn := func(x string) error { + return nil + } + + if p.Validate != nil { + validFn = p.Validate + } + + c.SetListener(func(line []rune, pos int, key rune) ([]rune, int, bool) { + if key == readline.CharEnter { + return nil, 0, false + } + + if firstListen { + firstListen = false + return nil, 0, false + } + + if !caughtup && out != "" { + if string(line) == out { + caughtup = true + } + if wroteErr { + return nil, 0, false + } + } + + err := validFn(string(line)) + if err != nil { + if _, ok := err.(*ValidationError); ok { + state = IconBad + } else { + rl.Close() + return nil, 0, false + } + } else { + state = IconGood + if p.IsConfirm { + state = IconInitial + } + } + + rl.SetPrompt(p.Indent + bold(state) + " " + bold(prompt)) + rl.Refresh() + wroteErr = false + + return nil, 0, false + }) + + for { + out, err = rl.Readline() + + var msg string + valid := true + oerr := validFn(out) + if oerr != nil { + if verr, ok := oerr.(*ValidationError); ok { + msg = verr.msg + valid = false + state = IconBad + } else { + return "", oerr + } + } + + if valid { + state = IconGood + break + } + + if err != nil { + switch err { + case readline.ErrInterrupt: + err = ErrInterrupt + case io.EOF: + err = ErrEOF + } + + break + } + + caughtup = false + + c.Stdin = io.MultiReader(bytes.NewBuffer([]byte(out)), os.Stdin) + rl, _ = readline.NewEx(c) + + firstListen = true + wroteErr = true + rl.SetPrompt("\n" + red(">> ") + msg + upLine(1) + "\r" + p.Indent + bold(state) + " " + bold(prompt)) + rl.Refresh() + } + + if wroteErr { + rl.Write([]byte(downLine(1) + clearLine + upLine(1) + "\r")) + } + + if err != nil { + if err.Error() == "Interrupt" { + err = ErrInterrupt + } + rl.Write([]byte("\n")) + return "", err + } + + echo := out + if p.Mask != 0 { + echo = strings.Repeat(string(p.Mask), len(echo)) + } + + if p.IsConfirm { + if strings.ToLower(echo) != "y" { + state = IconBad + err = ErrAbort + } else { + state = IconGood + } + } + + rl.Write([]byte(p.Indent + state + " " + prompt + faint(echo) + "\n")) + + return out, err +} diff --git a/prompt_test.go b/prompt_test.go new file mode 100644 index 0000000..e56703d --- /dev/null +++ b/prompt_test.go @@ -0,0 +1,43 @@ +package promptui + +import ( + "bytes" + "testing" +) + +func outputTest(mask rune, input, displayed, output, def string) func(t *testing.T) { + return func(t *testing.T) { + in := bytes.Buffer{} + out := bytes.Buffer{} + p := Prompt{ + Label: "test", + Default: def, + Mask: mask, + stdin: &in, + stdout: &out, + } + + in.Write([]byte(input + "\n")) + res, err := p.Run() + + if err != nil { + t.Errorf("error during prompt: %s", err) + } + + if res != output { + t.Errorf("wrong result: %s != %s", res, output) + } + + expected := "\033[32m✔\033[0m test: \033[2m" + displayed + "\033[0m\n" + if !bytes.Equal(out.Bytes(), []byte(expected)) { + t.Errorf("wrong output: %s != %s", out.Bytes(), expected) + } + + } +} + +func TestPrompt(t *testing.T) { + t.Run("can read input", outputTest(0x0, "hi", "hi", "hi", "")) + t.Run("displays masked values", outputTest('*', "hi", "**", "hi", "")) + t.Run("can use a default", outputTest(0x0, "", "hi", "hi", "hi")) +} diff --git a/promptui.go b/promptui.go new file mode 100644 index 0000000..010ffcc --- /dev/null +++ b/promptui.go @@ -0,0 +1,44 @@ +// Package promptui provides ui elements for the command line prompt. +package promptui + +import "errors" + +// ErrEOF is returned from prompts when EOF is encountered. +var ErrEOF = errors.New("^D") + +// ErrInterrupt is returned from prompts when an interrupt (ctrl-c) is +// encountered. +var ErrInterrupt = errors.New("^C") + +// ErrAbort is returned when confirm prompts are supplied "n" +var ErrAbort = errors.New("") + +// ValidateFunc validates the given input. It should return a ValidationError +// if the input is not valid. +type ValidateFunc func(string) error + +// ValidationError is the class of errors resulting from invalid inputs, +// returned from a ValidateFunc. +type ValidationError struct { + msg string +} + +// Error implements the error interface for ValidationError. +func (v *ValidationError) Error() string { + return v.msg +} + +// NewValidationError creates a new validation error with the given message. +func NewValidationError(msg string) *ValidationError { + return &ValidationError{msg: msg} +} + +// SuccessfulValue returns a value as if it were entered via prompt, valid +func SuccessfulValue(label, value string) string { + return IconGood + " " + label + ": " + faint(value) +} + +// FailedValue returns a value as if it were entered via prompt, invalid +func FailedValue(label, value string) string { + return IconBad + " " + label + ": " + faint(value) +} diff --git a/select.go b/select.go new file mode 100644 index 0000000..88c4a08 --- /dev/null +++ b/select.go @@ -0,0 +1,209 @@ +package promptui + +import ( + "bytes" + "io" + "os" + "strings" + + "github.com/chzyer/readline" +) + +// SelectedAdd is returned from SelectWithAdd when add is selected. +const SelectedAdd = -1 + +// Select represents a list for selecting a single item +type Select struct { + Label string // Label is the value displayed on the command line prompt. + Items []string // Items are the items to use in the list. + IsVimMode bool // Whether readline is using Vim mode. +} + +// Run runs the Select list. It returns the index of the selected element, +// and its value. +func (s *Select) Run() (int, string, error) { + return s.innerRun(0, ' ') +} + +func (s *Select) innerRun(starting int, top rune) (int, string, error) { + stdin := readline.NewCancelableStdin(os.Stdin) + c := &readline.Config{} + err := c.Init() + if err != nil { + return 0, "", err + } + + c.Stdin = stdin + + if s.IsVimMode { + c.VimMode = true + } + + prompt := s.Label + ": " + + c.HistoryLimit = -1 + c.UniqueEditLine = true + + start := 0 + end := 4 + + if len(s.Items) <= end { + end = len(s.Items) - 1 + } + + selected := starting + + rl, err := readline.NewEx(c) + if err != nil { + return 0, "", err + } + + rl.Write([]byte(hideCursor)) + rl.Write([]byte(strings.Repeat("\n", end-start+1))) + + counter := 0 + + rl.Operation.ExitVimInsertMode() // Never use insert mode for selects + + c.SetListener(func(line []rune, pos int, key rune) ([]rune, int, bool) { + if rl.Operation.IsEnableVimMode() { + rl.Operation.ExitVimInsertMode() + // Remap j and k for down/up selections immediately after an + // `i` press + switch key { + case 'j': + key = readline.CharNext + case 'k': + key = readline.CharPrev + } + } + + switch key { + case readline.CharEnter: + return nil, 0, true + case readline.CharNext: + switch selected { + case len(s.Items) - 1: + case end: + start++ + end++ + fallthrough + default: + selected++ + } + case readline.CharPrev: + switch selected { + case 0: + case start: + start-- + end-- + fallthrough + default: + selected-- + } + } + + list := make([]string, end-start+1) + for i := start; i <= end; i++ { + page := ' ' + selection := " " + item := s.Items[i] + + switch i { + case 0: + page = top + case len(s.Items) - 1: + case start: + page = '↑' + case end: + page = '↓' + } + if i == selected { + selection = "▸" + item = underlined(item) + } + list[i-start] = clearLine + "\r" + string(page) + " " + selection + " " + item + } + + prefix := "" + prefix += upLine(uint(len(list))) + "\r" + clearLine + p := prefix + bold(IconInitial) + " " + bold(prompt) + downLine(1) + strings.Join(list, downLine(1)) + rl.SetPrompt(p) + rl.Refresh() + + counter++ + + return nil, 0, true + }) + + _, err = rl.Readline() + rl.Close() + + if err != nil { + switch { + case err == readline.ErrInterrupt, err.Error() == "Interrupt": + err = ErrInterrupt + case err == io.EOF: + err = ErrEOF + } + + rl.Write([]byte("\n")) + rl.Write([]byte(showCursor)) + rl.Refresh() + return 0, "", err + } + + rl.Write(bytes.Repeat([]byte(clearLine+upLine(1)), end-start+1)) + rl.Write([]byte("\r")) + + out := s.Items[selected] + rl.Write([]byte(IconGood + " " + prompt + faint(out) + "\n")) + + rl.Write([]byte(showCursor)) + return selected, out, err +} + +// SelectWithAdd represents a list for selecting a single item, or selecting +// a newly created item. +type SelectWithAdd struct { + Label string // Label is the value displayed on the command line prompt. + Items []string // Items are the items to use in the list. + + AddLabel string // The label used in the item list for creating a new item. + + // Validate is optional. If set, this function is used to validate the input + // after each character entry. + Validate ValidateFunc + + IsVimMode bool // Whether readline is using Vim mode. +} + +// Run runs the Select list. It returns the index of the selected element, +// and its value. If a new element is created, -1 is returned as the index. +func (sa *SelectWithAdd) Run() (int, string, error) { + if len(sa.Items) > 0 { + newItems := append([]string{sa.AddLabel}, sa.Items...) + + s := Select{ + Label: sa.Label, + Items: newItems, + IsVimMode: sa.IsVimMode, + } + + selected, value, err := s.innerRun(1, '+') + if err != nil || selected != 0 { + return selected - 1, value, err + } + + // XXX run through terminal for windows + os.Stdout.Write([]byte(upLine(1) + "\r" + clearLine)) + } + + p := Prompt{ + Label: sa.AddLabel, + Validate: sa.Validate, + IsVimMode: sa.IsVimMode, + } + value, err := p.Run() + return SelectedAdd, value, err +} diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..c3afd29 --- /dev/null +++ b/styles.go @@ -0,0 +1,19 @@ +// +build !windows + +package promptui + +var ( + bold = Styler(FGBold) + faint = Styler(FGFaint) + underlined = Styler(FGUnderline) +) + +// Icons used for displaying prompts or status +var ( + IconInitial = Styler(FGBlue)("?") + IconGood = Styler(FGGreen)("✔") + IconWarn = Styler(FGYellow)("⚠") + IconBad = Styler(FGRed)("✗") +) + +var red = Styler(FGBold, FGRed) diff --git a/styles_windows.go b/styles_windows.go new file mode 100644 index 0000000..52b8d4e --- /dev/null +++ b/styles_windows.go @@ -0,0 +1,17 @@ +package promptui + +var ( + bold = Styler(FGBold) + faint = Styler(FGFaint) + underlined = Styler(FGUnderline) +) + +// Icons used for displaying prompts or status +var ( + IconInitial = Styler(FGBlue)("?") + IconGood = Styler(FGGreen)("v") + IconWarn = Styler(FGYellow)("!") + IconBad = Styler(FGRed)("x") +) + +var red = Styler(FGBold, FGRed)