388 lines
10 KiB
Go
388 lines
10 KiB
Go
//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
|
|
}
|