├── .gitignore ├── LICENSE ├── README.md └── menu ├── doc.go ├── layout.go ├── menu.go └── menu_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-menu 2 | A library for building simple, interactive, menus in Go 3 | 4 | ![Screen Shot](https://s3.amazonaws.com/turret-io.media/images/screen_shot.png) 5 | 6 | See https://godoc.org/github.com/turret-io/go-menu/menu for docs 7 | -------------------------------------------------------------------------------- /menu/doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 TD Internet Solutions, LLC. All rights reserved. 2 | // Use of this source code is governed by a MIT-style 3 | // license that can be found in the LICENSE file. 4 | 5 | /* 6 | The go-menu package provides a library to build simple, 7 | interactive, command line menus in Go. 8 | 9 | Installation: 10 | 11 | import "github.com/turret-io/go-menu/menu" 12 | 13 | Example: 14 | 15 | func cmd1(args ...string) error { 16 | // Do something 17 | fmt.Println("Output of cmd1") 18 | return nil 19 | } 20 | 21 | func cmd2(args ...string) error { 22 | //Do something 23 | fmt.Println("Output of cmd2") 24 | return nil 25 | } 26 | 27 | func main() { 28 | commandOptions := []menu.CommandOption{ 29 | menu.CommandOption{"command1", "Runs command1", cmd1}, 30 | menu.CommandOption{"command2", "Runs command2", cmd2}, 31 | } 32 | 33 | menuOptions := menu.NewMenuOptions("'menu' for help > ", 0) 34 | 35 | menu := menu.NewMenu(commandOptions, menuOptions) 36 | menu.Start() 37 | } 38 | 39 | Notes: 40 | 41 | Typing "exit" or "quit" at the prompt will exit the program. 42 | 43 | Typing "menu" will display the menu. 44 | */ 45 | package menu 46 | -------------------------------------------------------------------------------- /menu/layout.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "text/tabwriter" 7 | ) 8 | 9 | // Handle building menu layout 10 | func layoutMenu(w *tabwriter.Writer, cmds []CommandOption, width int) { 11 | fmt.Fprintln(w, "*\tCommand\tDescription\t") 12 | for i := range cmds { 13 | // Write command 14 | fmt.Fprintf(w, "*\t%s\t", cmds[i].Command) 15 | 16 | // Check description length 17 | description_length := len(cmds[i].Description) 18 | 19 | if description_length <= width { 20 | fmt.Fprintf(w, "%s\t\n", cmds[i].Description) 21 | continue 22 | } 23 | 24 | if description_length > width { 25 | layoutLongDescription(w, cmds[i].Description, width) 26 | } 27 | 28 | } 29 | fmt.Fprintln(w) 30 | w.Flush() 31 | } 32 | 33 | // Return tokens up cumulative maxsize 34 | func getDescriptionRange(tokens []string, start int, maxsize int) ([]string, int) { 35 | total := 0 36 | token_part := tokens[start:] 37 | for i := range token_part { 38 | length := len(token_part[i]) 39 | if total+length > maxsize { 40 | return token_part[0 : i-1], start + i 41 | } 42 | total = total + length 43 | } 44 | return token_part[0:], -1 45 | } 46 | 47 | func layoutLongDescription(w *tabwriter.Writer, d string, width int) { 48 | 49 | // Tokenize description 50 | tokens := strings.Fields(d) 51 | 52 | // Get description for range 53 | description, lastIndex := getDescriptionRange(tokens, 0, width) 54 | 55 | // Write first MAX_LENGTH of description 56 | fmt.Fprintf(w, "%s\t\n", strings.Join(description, " ")) 57 | 58 | for { 59 | if lastIndex == -1 { 60 | break 61 | } 62 | 63 | description, lastIndex = getDescriptionRange(tokens, lastIndex, width) 64 | fmt.Fprintf(w, "*\t\t%s\t\n", strings.Join(description, " ")) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /menu/menu.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "text/tabwriter" 10 | ) 11 | 12 | // Main struct to handle options for Command, Description, and the 13 | // function that should be called 14 | type CommandOption struct { 15 | Command, Description string 16 | Function func(args ...string) error 17 | } 18 | 19 | // MenuOptions sets prompt, character width of menu, and command 20 | // used to display the menu 21 | type MenuOptions struct { 22 | Prompt string 23 | MenuLength int 24 | MenuCommand string 25 | } 26 | 27 | // Menu struct encapsulates Commands and Options 28 | type Menu struct { 29 | Commands []CommandOption 30 | Options MenuOptions 31 | } 32 | 33 | // Setup the options for the menu. 34 | // 35 | // An empty string for prompt and a length of 0 will use the 36 | // default "> " prompt and 100 character wide menu. An empty 37 | // string for menuCommand will use the default 'menu' command. 38 | func NewMenuOptions(prompt string, length int, menuCommand string) MenuOptions { 39 | return MenuOptions{prompt, length, menuCommand} 40 | } 41 | 42 | // Trim whitespace, newlines, and create command+arguments slice 43 | func cleanCommand(cmd string) ([]string, error) { 44 | cmd_args := strings.Split(strings.Trim(cmd, " \r\n"), " ") 45 | return cmd_args, nil 46 | } 47 | 48 | // Creates a new menu with options 49 | func NewMenu(cmds []CommandOption, options MenuOptions) *Menu { 50 | if options.Prompt == "" { 51 | options.Prompt = "> " 52 | } 53 | 54 | if options.MenuLength == 0 { 55 | options.MenuLength = 100 56 | } 57 | 58 | if options.MenuCommand == "" { 59 | options.MenuCommand = "menu" 60 | } 61 | 62 | return &Menu{cmds, options} 63 | } 64 | 65 | func (m *Menu) prompt() { 66 | fmt.Print(m.Options.Prompt) 67 | } 68 | 69 | // Write menu from CommandOptions with tabwriter 70 | func (m *Menu) menu() { 71 | w := new(tabwriter.Writer) 72 | w.Init(os.Stdout, 5, 0, 1, ' ', 0) 73 | layoutMenu(w, m.Commands, m.Options.MenuLength) 74 | } 75 | 76 | // Wrapper for providing Stdin to the main menu loop 77 | func (m *Menu) Start() { 78 | m.start(os.Stdin) 79 | } 80 | 81 | // Main loop 82 | func (m *Menu) start(reader io.Reader) { 83 | m.menu() 84 | MainLoop: 85 | for { 86 | input := bufio.NewReader(reader) 87 | // Prompt for input 88 | m.prompt() 89 | 90 | inputString, err := input.ReadString('\n') 91 | if err != nil { 92 | // If we didn't receive anything from ReadString 93 | // we shouldn't continue because we're not blocking 94 | // anymore but we also don't have any data 95 | break MainLoop 96 | } 97 | 98 | cmd, _ := cleanCommand(inputString) 99 | if len(cmd) < 1 { 100 | break MainLoop 101 | } 102 | // Route the first index of the cmd slice to the appropriate case 103 | Route: 104 | switch cmd[0] { 105 | case "exit", "quit": 106 | fmt.Println("Exiting...") 107 | break MainLoop 108 | 109 | case m.Options.MenuCommand: 110 | m.menu() 111 | break 112 | 113 | default: 114 | // Loop through commands and find the right one 115 | // Probably a more efficient way to do this, but unless we have 116 | // tons of commands, it probably doesn't matter 117 | for i := range m.Commands { 118 | if m.Commands[i].Command == cmd[0] { 119 | err := m.Commands[i].Function(cmd[1:]...) 120 | if err != nil { 121 | panic(err) 122 | } 123 | 124 | break Route 125 | } 126 | } 127 | // Shouldn't get here if we found a command 128 | fmt.Println("Unknown command") 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /menu/menu_test.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | const cmd = "cmd" 10 | const desc = "this is a command" 11 | 12 | const cmdwin = "cmdwin" 13 | const descwin = "this is a windows command" 14 | 15 | var cmdwininvoked bool 16 | var cmdwinargs []string 17 | 18 | func getOpts() []CommandOption { 19 | return []CommandOption{ 20 | CommandOption{cmd, desc, func(...string) error { 21 | fmt.Println("in function") 22 | return nil 23 | }}, 24 | CommandOption{cmdwin, descwin, func(args ...string) error { 25 | fmt.Println("in cmdwin function") 26 | cmdwininvoked = true 27 | cmdwinargs = args 28 | return nil 29 | }}, 30 | } 31 | } 32 | 33 | func getMenuOpts() MenuOptions { 34 | return NewMenuOptions("", 0, "") 35 | } 36 | 37 | // Test menu creation with default options 38 | func TestMenuOptions(t *testing.T) { 39 | cmdOpts := getOpts() 40 | menuOpts := MenuOptions{} 41 | 42 | menu := NewMenu(cmdOpts, menuOpts) 43 | 44 | if menu.Commands[0].Command != cmd { 45 | t.Error("Command is not set") 46 | } 47 | 48 | if menu.Commands[0].Description != desc { 49 | t.Error("Description is not set") 50 | } 51 | 52 | if menu.Options.Prompt != "> " { 53 | t.Error("Unexpected prompt") 54 | } 55 | 56 | if menu.Options.MenuLength != 100 { 57 | t.Error("Unexpected menu length") 58 | } 59 | 60 | if menu.Options.MenuCommand != "menu" { 61 | t.Error("Unexpected MenuCommand") 62 | } 63 | } 64 | 65 | // Test that the menu struct is created 66 | func TestSimpleMenu(t *testing.T) { 67 | cmdOpts := getOpts() 68 | menuOpts := getMenuOpts() 69 | 70 | menu := NewMenu(cmdOpts, menuOpts) 71 | 72 | if menu.Commands[0].Command != cmd { 73 | t.Error("Command is not set") 74 | } 75 | 76 | if menu.Commands[0].Description != desc { 77 | t.Error("Description is not set") 78 | } 79 | 80 | if menu.Options.Prompt != "> " { 81 | t.Error("Unexpected prompt") 82 | } 83 | 84 | if menu.Options.MenuLength != 100 { 85 | t.Error("Unexpected menu length") 86 | } 87 | } 88 | 89 | // Run a simple test on the menu using junk as input 90 | func TestJunkInput(t *testing.T) { 91 | cmdOpts := getOpts() 92 | menuOpts := getMenuOpts() 93 | 94 | menu := NewMenu(cmdOpts, menuOpts) 95 | 96 | input := bytes.NewReader([]byte("blah\n")) 97 | menu.start(input) 98 | } 99 | 100 | // Run a simple test using good data as input 101 | func TestGoodInput(t *testing.T) { 102 | cmdOpts := getOpts() 103 | menuOpts := getMenuOpts() 104 | 105 | menu := NewMenu(cmdOpts, menuOpts) 106 | 107 | input := bytes.NewReader([]byte(fmt.Sprintf("%s\n", cmd))) 108 | menu.start(input) 109 | } 110 | 111 | // Test Windows commands, which will have "\r\n" line endings 112 | // instead of the "\n" seen on macOS or Linux 113 | func TestWindowsLineEndingsWithoutArg(t *testing.T) { 114 | cmdOpts := getOpts() 115 | menuOpts := getMenuOpts() 116 | 117 | menu := NewMenu(cmdOpts, menuOpts) 118 | 119 | cmdwininvoked = false 120 | cmdwinargs = []string{} 121 | 122 | input := bytes.NewReader([]byte(fmt.Sprintf("%s\r\n", cmdwin))) 123 | menu.start(input) 124 | 125 | if !cmdwininvoked { 126 | t.Error("Expected cmdwin to have been invoked") 127 | } 128 | 129 | if len(cmdwinargs) > 0 { 130 | t.Error("Expected cmdwinargs to be empty") 131 | } 132 | } 133 | 134 | // Test Windows commands, which will have "\r\n" line endings 135 | // instead of the "\n" seen on macOS or Linux 136 | func TestWindowsLineEndingsWithArg(t *testing.T) { 137 | cmdOpts := getOpts() 138 | menuOpts := getMenuOpts() 139 | 140 | menu := NewMenu(cmdOpts, menuOpts) 141 | 142 | cmdwininvoked = false 143 | cmdwinargs = []string{} 144 | 145 | arg := "hello" 146 | 147 | input := bytes.NewReader([]byte(fmt.Sprintf("%s %s\r\n", cmdwin, arg))) 148 | menu.start(input) 149 | 150 | if !cmdwininvoked { 151 | t.Error("Expected cmdwin to have been invoked") 152 | } 153 | 154 | if len(cmdwinargs) != 1 || cmdwinargs[0] != arg { 155 | t.Error("Expected cmdwinargs to contain the correct argument") 156 | } 157 | } 158 | --------------------------------------------------------------------------------