//Package wmenu creates menus for cli programs. //It uses wlog for it's interface with the command line. //It uses os.Stdin, os.Stdout, and os.Stderr with concurrency by default. //wmenu allows you to change the color of the different parts of the menu. //This package also creates it's own error structure so you can type assert if you need to. //wmenu will validate all responses before calling any function. //It will also figure out which function should be called so you don't have to. package wmenu import ( "fmt" "io" "os" "regexp" "strconv" "strings" "github.com/mattn/go-isatty" wlog "gopkg.in/dixonwille/wlog.v2" ) const ( y = iota n ) var ( NoColor = os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd())) ) //Menu is used to display options to a user. //A user can then select options and Menu will validate the response and perform the correct action. type Menu struct { question string function func([]Opt) error options []Opt ui wlog.UI multiSeparator string allowMultiple bool loopOnInvalid bool clear bool tries int defIcon string isYN bool ynDef int } //NewMenu creates a menu with a wlog.UI as the writer. func NewMenu(question string) *Menu { //Create a default ui to use for menu var ui wlog.UI ui = wlog.New(os.Stdin, os.Stdout, os.Stderr) ui = wlog.AddConcurrent(ui) return &Menu{ question: question, function: nil, options: nil, ui: ui, multiSeparator: " ", allowMultiple: false, loopOnInvalid: false, clear: false, tries: 3, defIcon: "*", isYN: false, ynDef: 0, } } //AddColor will change the color of the menu items. //optionColor changes the color of the options. //questionColor changes the color of the questions. //errorColor changes the color of the question. //Use wlog.None if you do not want to change the color. func (m *Menu) AddColor(optionColor, questionColor, responseColor, errorColor wlog.Color) { if !NoColor { m.ui = wlog.AddColor(questionColor, errorColor, wlog.None, wlog.None, optionColor, responseColor, wlog.None, wlog.None, wlog.None, m.ui) } } //ClearOnMenuRun will clear the screen when a menu is ran. //This is checked when LoopOnInvalid is activated. //Meaning if an error occurred then it will clear the screen before asking again. func (m *Menu) ClearOnMenuRun() { m.clear = true } //SetSeparator sets the separator to use when multiple options are valid responses. //Default value is a space. func (m *Menu) SetSeparator(sep string) { m.multiSeparator = sep } //SetTries sets the number of tries on the loop before failing out. //Default is 3. //Negative values act like 0. func (m *Menu) SetTries(i int) { m.tries = i } //LoopOnInvalid is used if an invalid option was given then it will prompt the user again. func (m *Menu) LoopOnInvalid() { m.loopOnInvalid = true } //SetDefaultIcon sets the icon used to identify which options will be selected by default func (m *Menu) SetDefaultIcon(icon string) { m.defIcon = icon } //IsYesNo sets the menu to a yes/no state. //Does not show options but does ask question. //Will also parse the answer to allow for all variants of yes/no (IE Y yes No ...) //Specify the default value using def. 0 is for yes and 1 is for no. //Both will call the Action function you specified. // Opt{ID: 0, Text: "y"} for yes and Opt{ID: 1, Text: "n"} for no will be passed to the function. func (m *Menu) IsYesNo(def int) { m.isYN = true m.ynDef = def } //Option adds an option to the menu for the user to select from. //value is an empty interface that can be used to pass anything through to the function. //title is the string the user will select //isDefault is whether this option is a default option (IE when no options are selected). //function is what is called when only this option is selected. //If function is nil then it will default to the menu's Action. func (m *Menu) Option(title string, value interface{}, isDefault bool, function func(Opt) error) { option := newOption(len(m.options), title, value, isDefault, function) m.options = append(m.options, *option) } //Action adds a default action to use in certain scenarios. //If the selected option (by default or user selected) does not have a function applied to it this will be called. //If there are no default options and no option was selected this will be called with an option that has an ID of -1. func (m *Menu) Action(function func([]Opt) error) { m.function = function } //AllowMultiple will tell the menu to allow multiple selections. //The menu will fail if this is not called and mulple selections were selected. func (m *Menu) AllowMultiple() { m.allowMultiple = true } //ChangeReaderWriter changes where the menu listens and writes to. //reader is where user input is collected. //writer and errorWriter is where the menu should write to. func (m *Menu) ChangeReaderWriter(reader io.Reader, writer, errorWriter io.Writer) { var ui wlog.UI ui = wlog.New(reader, writer, errorWriter) m.ui = ui } //Run is used to execute the menu. //It will print to options and question to the screen. //It will only clear the screen if ClearOnMenuRun is activated. //This will validate all responses. //Errors are of type MenuError. func (m *Menu) Run() error { if m.clear { Clear() } valid := false var options []Opt //Loop and on error check if loopOnInvalid is enabled. //If it is Clear the screen and write error. //Then ask again for !valid { //step 1 print options to screen m.print() //step 2 ask question, get and validate response opt, err := m.ask() if err != nil { m.tries = m.tries - 1 if !IsMenuErr(err) { err = newMenuError(err, "", m.triesLeft()) } if m.loopOnInvalid && m.tries > 0 { if m.clear { Clear() } m.ui.Error(err.Error()) } else { return err } } else { options = opt valid = true } } //step 3 call appropriate action with the responses return m.callAppropriate(options) } func (m *Menu) callAppropriate(options []Opt) (err error) { if len(options) == 0 { return m.callAppropriateNoOptions() } if len(options) == 1 && options[0].function != nil { return options[0].function(options[0]) } return m.function(options) } func (m *Menu) callAppropriateNoOptions() (err error) { options := m.getDefault() if len(options) == 0 { return m.function([]Opt{{ID: -1}}) } if len(options) == 1 && options[0].function != nil { return options[0].function(options[0]) } return m.function(options) } //hide options when this is a yes or no func (m *Menu) print() { if !m.isYN { for _, opt := range m.options { icon := m.defIcon if !opt.isDefault { icon = "" } m.ui.Output(fmt.Sprintf("%d) %s%s", opt.ID, icon, opt.Text)) } } else { //TODO Allow user to specify what to use as value for YN options m.options = []Opt{} m.Option("y", "yes", m.ynDef == y, nil) m.Option("n", "no", m.ynDef == n, nil) } } func (m *Menu) ask() ([]Opt, error) { if m.isYN { if m.ynDef == y { m.question += " (Y/n)" } else { m.question += " (y/N)" } } trim := "" if m.multiSeparator == " " { trim = m.multiSeparator } else { trim = m.multiSeparator + " " } res, err := m.ui.Ask(m.question, trim) if err != nil { return nil, err } //Validate responses //Check if no responses are returned and no action to call if res == "" { //get default options opt := m.getDefault() if !m.validOptAndFunc(opt) { return nil, newMenuError(ErrNoResponse, "", m.triesLeft()) } return nil, nil } var responses []int if !m.isYN { responses, err = m.resToInt(res) if err != nil { return nil, err } err = m.validateResponses(responses) if err != nil { return nil, err } } else { responses, err = m.ynResParse(res) if err != nil { return nil, err } } //Parse responses and return them as options var finalOptions []Opt for _, response := range responses { finalOptions = append(finalOptions, m.options[response]) } return finalOptions, nil } //Converts the response string to a slice of ints, also validates along the way. func (m *Menu) resToInt(res string) ([]int, error) { resStrings := strings.Split(res, m.multiSeparator) //Check if we don't want multiple responses if !m.allowMultiple && len(resStrings) > 1 { return nil, newMenuError(ErrTooMany, "", m.triesLeft()) } //Convert responses to intigers var responses []int for _, response := range resStrings { //Check if it is an intiger response = strings.Trim(response, " ") r, err := strconv.Atoi(response) if err != nil { return nil, newMenuError(ErrInvalid, response, m.triesLeft()) } responses = append(responses, r) } return responses, nil } func (m *Menu) ynResParse(res string) ([]int, error) { resStrings := strings.Split(res, m.multiSeparator) if len(resStrings) > 1 { return nil, newMenuError(ErrTooMany, "", m.triesLeft()) } re := regexp.MustCompile("^\\s*(?:([Yy])(?:es|ES)?|([Nn])(?:o|O)?)\\s*$") matches := re.FindStringSubmatch(res) if len(matches) < 2 { return nil, newMenuError(ErrInvalid, res, m.triesLeft()) } if strings.ToLower(matches[1]) == "y" { return []int{y}, nil } return []int{n}, nil } //Check if response is in the range of options //If it is make sure it is not duplicated func (m *Menu) validateResponses(responses []int) error { var tmp []int for _, response := range responses { if response < 0 || len(m.options)-1 < response { return newMenuError(ErrInvalid, strconv.Itoa(response), m.triesLeft()) } if exist(tmp, response) { return newMenuError(ErrDuplicate, strconv.Itoa(response), m.triesLeft()) } tmp = append(tmp, response) } return nil } //Simply checks if number exists in the slice func exist(slice []int, number int) bool { for _, s := range slice { if number == s { return true } } return false } //gets a list of default options func (m *Menu) getDefault() []Opt { var opt []Opt for _, o := range m.options { if o.isDefault { opt = append(opt, o) } } return opt } //make sure that there is an action available to be called in certain cases //returns false if it chould not find an action for the number options available func (m *Menu) validOptAndFunc(opt []Opt) bool { if m.function == nil { if len(opt) == 1 && opt[0].function != nil { return true } return false } return true } func (m *Menu) triesLeft() int { if m.loopOnInvalid && m.tries > 0 { return m.tries } return 0 }