├── __snapshots__ ├── testsay-simple_ask.snap ├── testsay-simple_yn.snap ├── testheadersshort.snap ├── testheadersshort-table_with_only_one_column_header_set.snap ├── testsay-retry_yn.snap ├── testloading.snap ├── testprogressbar.snap └── testprogressspinner.snap ├── cursors_windows.go ├── cursors_darwin.go ├── cursors_linux.go ├── table_size_windows.go ├── go.mod ├── examples ├── styles │ └── styles_example.go ├── interactive │ └── interactive_example.go ├── table │ └── table_example.go └── progress │ └── progress_example.go ├── table_size_darwin.go ├── table_size_linux.go ├── progress_test.go ├── LICENSE ├── style_test.go ├── validators_test.go ├── interactive_test.go ├── validators.go ├── go.sum ├── style_windows.go ├── style_darwin.go ├── style_linux.go ├── README.md ├── progress.go ├── interactive.go ├── table_test.go └── table.go /__snapshots__/testsay-simple_ask.snap: -------------------------------------------------------------------------------- 1 | Did this work: -------------------------------------------------------------------------------- /__snapshots__/testsay-simple_yn.snap: -------------------------------------------------------------------------------- 1 | Do you want this to work [y/N]: -------------------------------------------------------------------------------- /__snapshots__/testheadersshort.snap: -------------------------------------------------------------------------------- 1 |  2 | 3 | test 4 | -------------------------------------------------------------------------------- /__snapshots__/testheadersshort-table_with_only_one_column_header_set.snap: -------------------------------------------------------------------------------- 1 |  2 | 3 | test 4 | -------------------------------------------------------------------------------- /cursors_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package clt 4 | 5 | const ( 6 | showCursor = "" 7 | hideCursor = "" 8 | ) 9 | -------------------------------------------------------------------------------- /cursors_darwin.go: -------------------------------------------------------------------------------- 1 | // +build darwin 2 | 3 | package clt 4 | 5 | const ( 6 | showCursor = "\x1b[?25h" 7 | hideCursor = "\x1b[?25l" 8 | ) 9 | -------------------------------------------------------------------------------- /cursors_linux.go: -------------------------------------------------------------------------------- 1 | // +build linux 2 | 3 | package clt 4 | 5 | const ( 6 | showCursor = "\x1b[?25h" 7 | hideCursor = "\x1b[?25l" 8 | ) 9 | -------------------------------------------------------------------------------- /__snapshots__/testsay-retry_yn.snap: -------------------------------------------------------------------------------- 1 | Do you want this to work [y/N]: 2 | 3 | Error: q is a not a valid option. Valid options are [yes y no n] 4 | 5 | 6 | Do you want this to work [y/N]: -------------------------------------------------------------------------------- /table_size_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package clt 4 | 5 | import "fmt" 6 | 7 | func getTerminalSize() (width, height int, err error) { 8 | return -1, -1, fmt.Errorf("fuck windows") 9 | } 10 | -------------------------------------------------------------------------------- /__snapshots__/testloading.snap: -------------------------------------------------------------------------------- 1 | [?25l ⠋ Testing a successful result[?25l ⠙ Testing a successful result[?25l ⠹ Testing a successful result[?25l ⠸ Testing a successful result[?25l 2 | -------------------------------------------------------------------------------- /__snapshots__/testprogressbar.snap: -------------------------------------------------------------------------------- 1 | [?25l Testing a successful result: [ ] 0%[?25l Testing a successful result: [========== ] 50%[?25l Testing a successful result: [====================] 100%[?25h 2 | -------------------------------------------------------------------------------- /__snapshots__/testprogressspinner.snap: -------------------------------------------------------------------------------- 1 | [?25l Testing a successful result...[|][?25l Testing a successful result...[/][?25l Testing a successful result...[-][?25l Testing a successful result...[\][?25h Testing a successful result...[OK] 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BTBurke/clt 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/BTBurke/snapshot v1.2.0 7 | github.com/stretchr/testify v1.2.2 8 | golang.org/x/crypto v0.0.0-20201217014255-9d1352758620 9 | golang.org/x/sys v0.0.0-20201218084310-7d0127a74742 // indirect 10 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /examples/styles/styles_example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/BTBurke/clt" 7 | ) 8 | 9 | func main() { 10 | fmt.Printf("This is %s text\n", clt.Styled(clt.Red).ApplyTo("red")) 11 | fmt.Printf("This is %s text\n", clt.Styled(clt.Blue, clt.Underline).ApplyTo("blue and underlined")) 12 | fmt.Printf("This is %s text\n", clt.Styled(clt.Blue, clt.Background(clt.White)).ApplyTo("blue on a white background")) 13 | fmt.Printf("This is %s text\n", clt.Styled(clt.Italic).ApplyTo("italic")) 14 | fmt.Printf("This is %s text\n", clt.Styled(clt.Bold).ApplyTo("bold")) 15 | fmt.Printf("This is %s text\n", clt.SStyled("red underline", clt.Red, clt.Underline)) 16 | } 17 | -------------------------------------------------------------------------------- /table_size_darwin.go: -------------------------------------------------------------------------------- 1 | // +build darwin 2 | 3 | package clt 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | "unsafe" 9 | ) 10 | 11 | // Magic from the go source for ssh/terminal to find terminal size. Because it is 12 | // difficult to test this implementation across os/terminal combinations, you can skip 13 | // this check by setting Table.SkipTermSize=true and set the Table.maxWidth and 14 | // Table.MaxHeight manually. Or, Table will set appropriate conservative defaults. 15 | func getTerminalSize() (width, height int, err error) { 16 | fd := os.Stdout.Fd() 17 | var dimensions [4]uint16 18 | 19 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&dimensions)), 0, 0, 0); err != 0 { 20 | return -1, -1, err 21 | } 22 | return int(dimensions[1]), int(dimensions[0]), nil 23 | } 24 | -------------------------------------------------------------------------------- /table_size_linux.go: -------------------------------------------------------------------------------- 1 | // +build linux 2 | 3 | package clt 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | "unsafe" 9 | ) 10 | 11 | // Magic from the go source for ssh/terminal to find terminal size. Because it is 12 | // difficult to test this implementation across os/terminal combinations, you can skip 13 | // this check by setting Table.SkipTermSize=true and set the Table.maxWidth and 14 | // Table.MaxHeight manually. Or, Table will set appropriate conservative defaults. 15 | func getTerminalSize() (width, height int, err error) { 16 | fd := os.Stdout.Fd() 17 | var dimensions [4]uint16 18 | 19 | if _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&dimensions)), 0, 0, 0); err != 0 { 20 | return -1, -1, err 21 | } 22 | return int(dimensions[1]), int(dimensions[0]), nil 23 | } 24 | -------------------------------------------------------------------------------- /progress_test.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/BTBurke/snapshot" 9 | ) 10 | 11 | func TestProgressSpinner(t *testing.T) { 12 | out := bytes.NewBuffer(nil) 13 | 14 | p := NewProgressSpinner("Testing a successful result") 15 | p.output = out 16 | p.Start() 17 | time.Sleep(1 * time.Second) 18 | p.Success() 19 | snapshot.Assert(t, out.Bytes()) 20 | } 21 | 22 | func TestProgressBar(t *testing.T) { 23 | out := bytes.NewBuffer(nil) 24 | 25 | p := NewProgressBar("Testing a successful result") 26 | p.output = out 27 | p.Start() 28 | p.Update(0.5) 29 | p.Success() 30 | snapshot.Assert(t, out.Bytes()) 31 | } 32 | 33 | func TestLoading(t *testing.T) { 34 | out := bytes.NewBuffer(nil) 35 | 36 | p := NewLoadingMessage("Testing a successful result", Dots, time.Duration(0)) 37 | p.output = out 38 | p.Start() 39 | time.Sleep(1 * time.Second) 40 | p.Success() 41 | snapshot.Assert(t, out.Bytes()) 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Bryan Burke 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /style_test.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import "testing" 4 | 5 | func TestStyle1(t *testing.T) { 6 | s := Styled(Red) 7 | expectBefore := "\x1b[31m" 8 | expectAfter := "\x1b[39m" 9 | if s.before != expectBefore || s.after != expectAfter { 10 | t.Errorf("Expected:\nBefore: %s After: %s\nGot:\nBefore: %s After:%s\n", expectBefore, expectAfter, s.before, s.after) 11 | } 12 | } 13 | 14 | func TestStyle2(t *testing.T) { 15 | s2 := Styled(Red, Underline) 16 | expectBefore2 := "\x1b[31;4m" 17 | expectAfter2 := "\x1b[39;24m" 18 | if s2.before != expectBefore2 || s2.after != expectAfter2 { 19 | t.Errorf("Expected:\nBefore: %s After: %s\nGot:\nBefore: %s After: %s\n", expectBefore2, expectAfter2, s2.before, s2.after) 20 | } 21 | } 22 | 23 | func TestApplyTo(t *testing.T) { 24 | s := Styled(Red) 25 | testString := "This is a test" 26 | expect := "\x1b[31mThis is a test\x1b[39m" 27 | applyResult := s.ApplyTo(testString) 28 | if applyResult != expect { 29 | t.Errorf("Expected: %v\nGot: %v\n", expect, applyResult) 30 | } 31 | } 32 | 33 | func TestApplyTo2(t *testing.T) { 34 | s := Styled(Red, Underline) 35 | testString := "This is a test" 36 | expect := "\x1b[31;4mThis is a test\x1b[39;24m" 37 | applyResult := s.ApplyTo(testString) 38 | if applyResult != expect { 39 | t.Errorf("Expected: %v\nGot: %v\n", expect, applyResult) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /validators_test.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // TestYesNo tests inputs using yes and no values 8 | func TestYesNo(t *testing.T) { 9 | cases := []string{"yes", "y", "NO", "n"} 10 | 11 | for _, case1 := range cases { 12 | f := ValidateYesNo() 13 | if ok, _ := f(case1); !ok { 14 | t.Errorf("Failed YesNoValidation for %s", case1) 15 | } 16 | } 17 | 18 | cases = []string{"yes", "YES", "Y", "y"} 19 | for _, y := range cases { 20 | if !IsYes(y) { 21 | t.Errorf("Failed IsYes validation for %s", y) 22 | } 23 | } 24 | 25 | cases = []string{"no", "NO", "N", "n"} 26 | for _, n := range cases { 27 | if !IsNo(n) { 28 | t.Errorf("Failed IsNo validation for %s", n) 29 | } 30 | } 31 | } 32 | 33 | // TestOptionValidator tests building a new validator function with a list 34 | // of options. 35 | func TestOptionValidator(t *testing.T) { 36 | passCases := []string{"go", "capitals"} 37 | failCases := []string{"bruins", "rangers"} 38 | options := []string{"go", "washington", "capitals"} 39 | 40 | valFunc := AllowedOptions(options) 41 | for _, p := range passCases { 42 | if ok, _ := valFunc(p); !ok { 43 | t.Errorf("Failed OptionValidation for %s", p) 44 | } 45 | } 46 | 47 | for _, p := range failCases { 48 | if ok, _ := valFunc(p); ok { 49 | t.Errorf("Failed OptionValidation for %s", p) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /interactive_test.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "testing" 7 | 8 | "github.com/BTBurke/snapshot" 9 | ) 10 | 11 | func WithTestInput(input string) (*InteractiveSession, *bytes.Buffer) { 12 | var out bytes.Buffer 13 | return &InteractiveSession{ 14 | input: bufio.NewReader(bytes.NewBufferString(input)), 15 | output: &out, 16 | }, &out 17 | } 18 | 19 | func TestSay(t *testing.T) { 20 | 21 | tt := []struct { 22 | Name string 23 | Method string 24 | Prompt string 25 | Default string 26 | ValHint string 27 | Options map[string]string 28 | Input string 29 | Resp string 30 | Validators []ValidationFunc 31 | }{ 32 | {Name: "simple ask", Method: "ask", Prompt: "Did this work", Input: "yes\n", Resp: "yes"}, 33 | {Name: "simple yn", Method: "yn", Prompt: "Do you want this to work", Input: "y", Default: "n", Resp: "y"}, 34 | {Name: "retry yn", Method: "yn", Prompt: "Do you want this to work", Input: "q\ny", Default: "n", Resp: "y"}, 35 | } 36 | for _, tc := range tt { 37 | t.Run(tc.Name, func(t *testing.T) { 38 | sess, buf := WithTestInput(tc.Input) 39 | 40 | var got string 41 | switch tc.Method { 42 | case "ask": 43 | got = sess.ask(tc.Prompt, tc.Default, tc.ValHint, tc.Validators...) 44 | case "yn": 45 | got = sess.AskYesNo(tc.Prompt, tc.Default) 46 | default: 47 | t.Errorf("unexpected test: %s", tc.Method) 48 | } 49 | if tc.Resp != got { 50 | t.Errorf("interactive session returned bad response, expected %s, got %s", tc.Resp, got) 51 | } 52 | snapshot.Assert(t, buf.Bytes()) 53 | }) 54 | 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /validators.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ValidationFunc is a type alias for a validator that takes a string and 9 | // returns true if it passes validation. Error provides a helpful error 10 | // message to the user that is shown before re-asking the question. 11 | type ValidationFunc func(s string) (bool, error) 12 | 13 | // ValidateYesNo is a validation function that ensures that a yes/no question 14 | // receives a valid response. Use IsYes or IsNo to test for a particular 15 | // yes or no response. 16 | func ValidateYesNo() ValidationFunc { 17 | return func(s string) (bool, error) { 18 | options := []string{"yes", "y", "no", "n"} 19 | return validateOptions(strings.ToLower(s), options) 20 | } 21 | } 22 | 23 | // IsYes returns true if the response is some form of yes or y 24 | func IsYes(s string) bool { 25 | options := []string{"yes", "y"} 26 | ok, _ := validateOptions(strings.ToLower(s), options) 27 | return ok 28 | } 29 | 30 | // IsNo returns true if the response is some form of no or n 31 | func IsNo(s string) bool { 32 | options := []string{"no", "n"} 33 | ok, _ := validateOptions(strings.ToLower(s), options) 34 | return ok 35 | } 36 | 37 | // AllowedOptions builds a new validator from a []string of options 38 | func AllowedOptions(options []string) ValidationFunc { 39 | return func(s string) (bool, error) { 40 | return validateOptions(strings.ToLower(s), options) 41 | } 42 | } 43 | 44 | // Required validates that the length of the input is greater than 0 45 | func Required() ValidationFunc { 46 | return func(s string) (bool, error) { 47 | switch { 48 | case len(s) > 0: 49 | return true, nil 50 | default: 51 | return false, fmt.Errorf("A response is required.") 52 | } 53 | } 54 | } 55 | 56 | // validateOptions returns true if the given string appears in the list of valid 57 | // options. 58 | func validateOptions(s string, options []string) (bool, error) { 59 | l := strings.ToLower(s) 60 | for _, option := range options { 61 | if l == option { 62 | return true, nil 63 | } 64 | } 65 | return false, fmt.Errorf("%s is a not a valid option. Valid options are %v", s, options) 66 | } 67 | -------------------------------------------------------------------------------- /examples/interactive/interactive_example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/BTBurke/clt" 7 | ) 8 | 9 | func main() { 10 | fmt.Println("------- Pagination Example --------") 11 | Pagination() 12 | 13 | fmt.Println("\n\n\n\n\n------- Yes/No Example --------") 14 | AskYesNo() 15 | 16 | fmt.Println("\n\n\n\n\n------- Choice Example --------") 17 | ChooseFromOptions() 18 | 19 | fmt.Println("\n\n\n\n\n------- Password Example -------") 20 | PasswordPrompt() 21 | } 22 | 23 | func Pagination() { 24 | i := clt.NewInteractiveSession() 25 | i.Say("This can be a really long screed that needs to be paginated. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") 26 | i.Pause() 27 | i.Say("And I could continue with more stuff...") 28 | } 29 | 30 | func AskYesNo() { 31 | i := clt.NewInteractiveSession() 32 | resp := i.Say("This is an example of asking a yes/no question and maybe you want to add a warning as well."). 33 | Warn("%s", clt.SStyled("Bad things can happen if you do this!", clt.Bold)). 34 | AskYesNo("Do you really want to do this?", "n") 35 | switch { 36 | case clt.IsYes(resp): 37 | i.Say("Ok, I'll go ahead and do that.") 38 | case clt.IsNo(resp): 39 | i.Say("Good idea, let's do that later.") 40 | } 41 | } 42 | 43 | func ChooseFromOptions() { 44 | choices := map[string]string{ 45 | "a": "Do task a", 46 | "b": "Do task b", 47 | "abort": "Let's get out of here", 48 | } 49 | i := clt.NewInteractiveSession() 50 | resp := i.Say("You can also create a list of options and let them select from the list."). 51 | AskFromTable("Pick a choice from the table", choices, "a") 52 | i.Say("Ok, let's do: %s", choices[resp]) 53 | } 54 | 55 | func PasswordPrompt() { 56 | i := clt.NewInteractiveSession() 57 | pw := i.AskPassword() 58 | i.Say("Shhh! Don't tell anyone your password is %s", pw) 59 | } 60 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BTBurke/snapshot v1.2.0 h1:VxsUbo9gwm11aKMslpptKt3aRu7/N9vY/NYDNo0XKHI= 2 | github.com/BTBurke/snapshot v1.2.0/go.mod h1:PVGE/CPAxI1dBEt4tw9DnKEeQDtV7mNbo5JTb3XqbE0= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 9 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 10 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 11 | golang.org/x/crypto v0.0.0-20201217014255-9d1352758620 h1:3wPMTskHO3+O6jqTEXyFcsnuxMQOqYSaHsDxcbUXpqA= 12 | golang.org/x/crypto v0.0.0-20201217014255-9d1352758620/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 13 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 14 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 15 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 16 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/sys v0.0.0-20201218084310-7d0127a74742 h1:+CBz4km/0KPU3RGTwARGh/noP3bEwtHcq+0YcBQM2JQ= 18 | golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM= 20 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 21 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= 22 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 23 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 24 | -------------------------------------------------------------------------------- /examples/table/table_example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/BTBurke/clt" 8 | ) 9 | 10 | func main() { 11 | sepLine := strings.Repeat("=", 15) 12 | 13 | // A simple table that should fit in a standard terminal width 80 14 | fmt.Printf("\n\n\n%s Simple Table Example %s\n\n\n", sepLine, sepLine) 15 | SimpleTable() 16 | 17 | // A table with long content that needs to be wrapped. The table 18 | // library has several strategies for fitting the content into the 19 | // available terminal space. 20 | fmt.Printf("\n\n\n%s Wrapped Table Example %s\n\n\n", sepLine, sepLine) 21 | WrappedTable() 22 | 23 | // Tables can be styled many ways, using the clt.Styled library 24 | fmt.Printf("\n\n\n%s Styled Table Example %s\n\n\n", sepLine, sepLine) 25 | StyledTable() 26 | 27 | fmt.Printf("\n\n") 28 | } 29 | 30 | func SimpleTable() { 31 | // A 3-column table 32 | t := clt.NewTable(3) 33 | 34 | // Set the headers and title 35 | t.ColumnHeaders("Column1", "Column2", "Column3") 36 | t.Title("Simple Example Table") 37 | 38 | // Add some rows 39 | t.AddRow("Col1 Line1", "Col2 Line1", "Col3 Line1") 40 | t.AddRow("Col1 Line2", "Col2 Line2", "Col3 Line2") 41 | 42 | // Print the table 43 | t.Show() 44 | } 45 | 46 | func WrappedTable() { 47 | // A 3-column table 48 | // Force the terminal size to be small to see wrapping behavior. 49 | // Normally, the terminal width is detected automatically, but 50 | // you can set the table MaxWidth explicitly when desired. 51 | t := clt.NewTable(3, clt.MaxWidth(50), clt.Spacing(2)). 52 | ColumnHeaders("Column1", "Column2", "Column3"). 53 | Title("Wrapped Example Table") 54 | 55 | // Add some rows 56 | t.AddRow("Col1 Line1", "Col2 Line1", "This is a pretty long description.") 57 | t.AddRow("Col1 Line2", "Col2 Line2", "This is another longish one.") 58 | 59 | // Print the table 60 | t.Show() 61 | } 62 | 63 | func StyledTable() { 64 | // A 3-column table 65 | t := clt.NewTable(2) 66 | 67 | // Set the headers and title 68 | t.ColumnHeaders("Status", "Reason") 69 | t.Title("Styled Example Table") 70 | 71 | // Set styles for each column 72 | t.ColumnStyles(clt.Styled(clt.Green), clt.Styled(clt.Default)) 73 | 74 | // Add some rows. The OK will be green. 75 | t.AddRow("OK", "Everything worked") 76 | 77 | // Add another row with custom styling to override the green column 78 | t.AddStyledRow(clt.StyledCell("FAIL", clt.Styled(clt.Red)), clt.StyledCell("Something bad happened", clt.Styled(clt.Default))) 79 | 80 | // Print the table 81 | t.Show() 82 | } 83 | -------------------------------------------------------------------------------- /style_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package clt 4 | 5 | import ( 6 | "bytes" 7 | ) 8 | 9 | // Color represents a ANSI-coded color style for text 10 | type Color struct { 11 | before int 12 | after int 13 | } 14 | 15 | // Codes returns ANSI styling values for a color 16 | func (c Color) Codes() (int, int) { return c.before, c.after } 17 | 18 | // Textstyle represents a ANSI-coded text style 19 | type Textstyle struct { 20 | before int 21 | after int 22 | } 23 | 24 | // Codes returns ANSI styling values for a textstyle 25 | func (t Textstyle) Codes() (int, int) { return t.before, t.after } 26 | 27 | // Styler is an interface that is fulfilled by either a Color 28 | // or Textstyle to be applied to a string 29 | type Styler interface { 30 | Codes() (int, int) 31 | } 32 | 33 | // Style represents a computed style from one or more colors or textstyles 34 | // as the ANSI code suitable for terminal output 35 | type Style struct { 36 | before string 37 | after string 38 | } 39 | 40 | // ApplyTo applies styles created using the Styled command to a string 41 | // to generate an styled output using ANSI terminal codes 42 | func (s *Style) ApplyTo(content string) string { 43 | var out bytes.Buffer 44 | out.WriteString(s.before) 45 | out.WriteString(content) 46 | out.WriteString(s.after) 47 | return out.String() 48 | } 49 | 50 | var ( 51 | // Colors 52 | Black = Color{30, 39} 53 | Red = Color{31, 39} 54 | Green = Color{32, 39} 55 | Yellow = Color{33, 39} 56 | Blue = Color{34, 39} 57 | Magenta = Color{35, 39} 58 | Cyan = Color{36, 39} 59 | White = Color{37, 39} 60 | Default = Color{39, 39} 61 | 62 | // Shortcut Colors 63 | K = Color{30, 39} 64 | R = Color{31, 39} 65 | G = Color{32, 39} 66 | Y = Color{33, 39} 67 | B = Color{34, 39} 68 | M = Color{35, 39} 69 | C = Color{36, 39} 70 | W = Color{37, 39} 71 | Def = Color{39, 39} 72 | 73 | // Textstyles 74 | Bold = Textstyle{1, 22} 75 | Italic = Textstyle{3, 23} 76 | Underline = Textstyle{4, 24} 77 | ) 78 | 79 | // Background returns a style that sets the background to the appropriate color 80 | func Background(c Color) Color { 81 | c.before += 10 82 | c.after += 10 83 | return c 84 | } 85 | 86 | // Styled contructs a composite style from one of more color or textstyle values. Styles 87 | // can be applied to a string via ApplyTo or as a shortcut use SStyled which returns a string directly 88 | // Example: Styled(White, Underline) 89 | func Styled(s ...Styler) *Style { 90 | switch { 91 | case len(s) == 1: 92 | var computedStyle Style 93 | computedStyle.before = "" 94 | computedStyle.after = "" 95 | return &computedStyle 96 | case len(s) > 1: 97 | var computedStyle Style 98 | var beforeConcat, afterConcat bytes.Buffer 99 | 100 | beforeConcat.WriteString("") 101 | afterConcat.WriteString("") 102 | 103 | for idx := range s { 104 | if idx < len(s)-1 { 105 | beforeConcat.WriteString("") 106 | afterConcat.WriteString("") 107 | } else { 108 | beforeConcat.WriteString("") 109 | afterConcat.WriteString("") 110 | } 111 | } 112 | computedStyle.before = beforeConcat.String() 113 | computedStyle.after = afterConcat.String() 114 | return &computedStyle 115 | } 116 | return &Style{} 117 | } 118 | 119 | // SStyled is a shorter version of Styled(s...).ApplyTo(content) 120 | func SStyled(content string, s ...Styler) string { 121 | return Styled(s...).ApplyTo(content) 122 | } 123 | -------------------------------------------------------------------------------- /style_darwin.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package clt 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | ) 9 | 10 | // Color represents a ANSI-coded color style for text 11 | type Color struct { 12 | before int 13 | after int 14 | } 15 | 16 | // Codes returns ANSI styling values for a color 17 | func (c Color) Codes() (int, int) { return c.before, c.after } 18 | 19 | // Textstyle represents a ANSI-coded text style 20 | type Textstyle struct { 21 | before int 22 | after int 23 | } 24 | 25 | // Codes returns ANSI styling values for a textstyle 26 | func (t Textstyle) Codes() (int, int) { return t.before, t.after } 27 | 28 | // Styler is an interface that is fulfilled by either a Color 29 | // or Textstyle to be applied to a string 30 | type Styler interface { 31 | Codes() (int, int) 32 | } 33 | 34 | // Style represents a computed style from one or more colors or textstyles 35 | // as the ANSI code suitable for terminal output 36 | type Style struct { 37 | before string 38 | after string 39 | } 40 | 41 | // ApplyTo applies styles created using the Styled command to a string 42 | // to generate an styled output using ANSI terminal codes 43 | func (s *Style) ApplyTo(content string) string { 44 | var out bytes.Buffer 45 | out.WriteString(s.before) 46 | out.WriteString(content) 47 | out.WriteString(s.after) 48 | return out.String() 49 | } 50 | 51 | var ( 52 | // Colors 53 | Black = Color{30, 39} 54 | Red = Color{31, 39} 55 | Green = Color{32, 39} 56 | Yellow = Color{33, 39} 57 | Blue = Color{34, 39} 58 | Magenta = Color{35, 39} 59 | Cyan = Color{36, 39} 60 | White = Color{37, 39} 61 | Default = Color{39, 39} 62 | 63 | // Shortcut Colors 64 | K = Color{30, 39} 65 | R = Color{31, 39} 66 | G = Color{32, 39} 67 | Y = Color{33, 39} 68 | B = Color{34, 39} 69 | M = Color{35, 39} 70 | C = Color{36, 39} 71 | W = Color{37, 39} 72 | Def = Color{39, 39} 73 | 74 | // Textstyles 75 | Bold = Textstyle{1, 22} 76 | Italic = Textstyle{3, 23} 77 | Underline = Textstyle{4, 24} 78 | ) 79 | 80 | // Background returns a style that sets the background to the appropriate color 81 | func Background(c Color) Color { 82 | c.before += 10 83 | c.after += 10 84 | return c 85 | } 86 | 87 | // Styled contructs a composite style from one of more color or textstyle values. Styles 88 | // can be applied to a string via ApplyTo or as a shortcut use SStyled which returns a string directly 89 | // Example: Styled(White, Underline) 90 | func Styled(s ...Styler) *Style { 91 | switch { 92 | case len(s) == 1: 93 | bef, aft := s[0].Codes() 94 | var computedStyle Style 95 | computedStyle.before = fmt.Sprintf("\x1b[%vm", bef) 96 | computedStyle.after = fmt.Sprintf("\x1b[%vm", aft) 97 | return &computedStyle 98 | case len(s) > 1: 99 | var computedStyle Style 100 | var beforeConcat, afterConcat bytes.Buffer 101 | 102 | beforeConcat.WriteString("\x1b[") 103 | afterConcat.WriteString("\x1b[") 104 | 105 | var bef, aft int 106 | for idx, sty := range s { 107 | bef, aft = sty.Codes() 108 | if idx < len(s)-1 { 109 | beforeConcat.WriteString(fmt.Sprintf("%v;", bef)) 110 | afterConcat.WriteString(fmt.Sprintf("%v;", aft)) 111 | } else { 112 | beforeConcat.WriteString(fmt.Sprintf("%vm", bef)) 113 | afterConcat.WriteString(fmt.Sprintf("%vm", aft)) 114 | } 115 | } 116 | computedStyle.before = beforeConcat.String() 117 | computedStyle.after = afterConcat.String() 118 | return &computedStyle 119 | } 120 | return &Style{} 121 | } 122 | 123 | // SStyled is a shorter version of Styled(s...).ApplyTo(content) 124 | func SStyled(content string, s ...Styler) string { 125 | return Styled(s...).ApplyTo(content) 126 | } 127 | -------------------------------------------------------------------------------- /style_linux.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package clt 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | ) 9 | 10 | // Color represents a ANSI-coded color style for text 11 | type Color struct { 12 | before int 13 | after int 14 | } 15 | 16 | // Codes returns ANSI styling values for a color 17 | func (c Color) Codes() (int, int) { return c.before, c.after } 18 | 19 | // Textstyle represents a ANSI-coded text style 20 | type Textstyle struct { 21 | before int 22 | after int 23 | } 24 | 25 | // Codes returns ANSI styling values for a textstyle 26 | func (t Textstyle) Codes() (int, int) { return t.before, t.after } 27 | 28 | // Styler is an interface that is fulfilled by either a Color 29 | // or Textstyle to be applied to a string 30 | type Styler interface { 31 | Codes() (int, int) 32 | } 33 | 34 | // Style represents a computed style from one or more colors or textstyles 35 | // as the ANSI code suitable for terminal output 36 | type Style struct { 37 | before string 38 | after string 39 | } 40 | 41 | // ApplyTo applies styles created using the Styled command to a string 42 | // to generate an styled output using ANSI terminal codes 43 | func (s *Style) ApplyTo(content string) string { 44 | var out bytes.Buffer 45 | out.WriteString(s.before) 46 | out.WriteString(content) 47 | out.WriteString(s.after) 48 | return out.String() 49 | } 50 | 51 | var ( 52 | // Colors 53 | Black = Color{30, 39} 54 | Red = Color{31, 39} 55 | Green = Color{32, 39} 56 | Yellow = Color{33, 39} 57 | Blue = Color{34, 39} 58 | Magenta = Color{35, 39} 59 | Cyan = Color{36, 39} 60 | White = Color{37, 39} 61 | Default = Color{39, 39} 62 | 63 | // Shortcut Colors 64 | K = Color{30, 39} 65 | R = Color{31, 39} 66 | G = Color{32, 39} 67 | Y = Color{33, 39} 68 | B = Color{34, 39} 69 | M = Color{35, 39} 70 | C = Color{36, 39} 71 | W = Color{37, 39} 72 | Def = Color{39, 39} 73 | 74 | // Textstyles 75 | Bold = Textstyle{1, 22} 76 | Italic = Textstyle{3, 23} 77 | Underline = Textstyle{4, 24} 78 | ) 79 | 80 | // Background returns a style that sets the background to the appropriate color 81 | func Background(c Color) Color { 82 | c.before += 10 83 | c.after += 10 84 | return c 85 | } 86 | 87 | // Styled contructs a composite style from one of more color or textstyle values. Styles 88 | // can be applied to a string via ApplyTo or as a shortcut use SStyled which returns a string directly 89 | // Example: Styled(White, Underline) 90 | func Styled(s ...Styler) *Style { 91 | switch { 92 | case len(s) == 1: 93 | bef, aft := s[0].Codes() 94 | var computedStyle Style 95 | computedStyle.before = fmt.Sprintf("\x1b[%vm", bef) 96 | computedStyle.after = fmt.Sprintf("\x1b[%vm", aft) 97 | return &computedStyle 98 | case len(s) > 1: 99 | var computedStyle Style 100 | var beforeConcat, afterConcat bytes.Buffer 101 | 102 | beforeConcat.WriteString("\x1b[") 103 | afterConcat.WriteString("\x1b[") 104 | 105 | var bef, aft int 106 | for idx, sty := range s { 107 | bef, aft = sty.Codes() 108 | if idx < len(s)-1 { 109 | beforeConcat.WriteString(fmt.Sprintf("%v;", bef)) 110 | afterConcat.WriteString(fmt.Sprintf("%v;", aft)) 111 | } else { 112 | beforeConcat.WriteString(fmt.Sprintf("%vm", bef)) 113 | afterConcat.WriteString(fmt.Sprintf("%vm", aft)) 114 | } 115 | } 116 | computedStyle.before = beforeConcat.String() 117 | computedStyle.after = afterConcat.String() 118 | return &computedStyle 119 | } 120 | return &Style{} 121 | } 122 | 123 | // SStyled is a shorter version of Styled(s...).ApplyTo(content) 124 | func SStyled(content string, s ...Styler) string { 125 | return Styled(s...).ApplyTo(content) 126 | } 127 | -------------------------------------------------------------------------------- /examples/progress/progress_example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "sync" 7 | "time" 8 | 9 | "github.com/BTBurke/clt" 10 | ) 11 | 12 | func main() { 13 | 14 | // This is a basic loading indicator that disappears after loading is complete. 15 | // Unlike progress bars or spinners, there is no indication of success or failure. 16 | // This is useful when making short server calls. The delay parameter 17 | // prevents flashing the loading symbol. If your call completes within 18 | // this delay parameter, the loading status will never be shown. 19 | fmt.Println("\nShowing a loading symbol while we make a remote call:") 20 | pL := clt.NewLoadingMessage("Loading...", clt.Dots, 100*time.Millisecond) 21 | pL.Start() 22 | time.Sleep(4 * time.Second) 23 | pL.Success() 24 | 25 | // An example of a progress spinner that succeeds. Calling Start() 26 | // starts a new go routine to render the spinner and returns control 27 | // to the calling function. You must then call Success() to terminate 28 | // the go routine and show the user the OK. 29 | fmt.Println("\nDoing something that succeeds after 3 seconds:") 30 | p := clt.NewProgressSpinner("Testing a successful result") 31 | p.Start() 32 | time.Sleep(3 * time.Second) 33 | p.Success() 34 | 35 | // An example of a progress spinner that fails. Calling Fail() will 36 | // let the user know the action failed. 37 | fmt.Println("\nDoing something that fails after 3 seconds:") 38 | pF := clt.NewProgressSpinner("Testing a failed result") 39 | pF.Start() 40 | time.Sleep(3 * time.Second) 41 | pF.Fail() 42 | 43 | // An example of a progress bar that succeeds. You must call 44 | // Update() with the completion percentage (float64 between 45 | // 0.0 and 1.0). Finally, call Success() or Fail() to terminate 46 | // the go routine. 47 | fmt.Println("\nDoing something that eventually succeeds:") 48 | pB := clt.NewProgressBar("Implement progress bar") 49 | pB.Start() 50 | for i := 0; i < 50; i++ { 51 | pB.Update(float64(i) / 50.0) 52 | time.Sleep(time.Duration(50) * time.Millisecond) 53 | } 54 | pB.Success() 55 | 56 | // An example of a progress bar that fails. You must call 57 | // Update() with the completion percentage (float64 between 58 | // 0.0 and 1.0). 59 | fmt.Println("\nDoing something that eventually fails:") 60 | pB2 := clt.NewProgressBar("Implement progress bar") 61 | pB2.Start() 62 | for i := 0; i < 20; i++ { 63 | pB2.Update(float64(i) / 50.0) 64 | time.Sleep(time.Duration(50) * time.Millisecond) 65 | } 66 | pB2.Fail() 67 | 68 | // An example of an incremental progress bar. See incremental_example.go. 69 | fmt.Println("\nDoing incremental progress from multiple go routimes:") 70 | incremental() 71 | 72 | } 73 | 74 | func incremental() { 75 | // This is an incremental progress example. It starts a progress bar with 10 total steps then some go routines 76 | // to simulate doing work and then updates the progress as each finishes 77 | 78 | p := clt.NewIncrementalProgressBar(10, "Doing work") 79 | 80 | ch := make(chan int, 1) 81 | var wg sync.WaitGroup 82 | 83 | // start 3 go routines to do some work. Pass them a copy of the progress bar so they can 84 | // call increment after each task is done 85 | for i := 0; i < 3; i++ { 86 | wg.Add(1) 87 | go func(ch chan int, p *clt.Progress) { 88 | defer wg.Done() 89 | for range ch { 90 | time.Sleep(time.Duration(rand.Intn(1000) * 1000000)) 91 | p.Increment() 92 | } 93 | }(ch, p) 94 | } 95 | 96 | // start the bar then pass it some work to do 97 | p.Start() 98 | for i := 0; i < 10; i++ { 99 | ch <- i 100 | } 101 | // wait until all the work is done 102 | wg.Done() 103 | 104 | // call Success to close the progress channel and update to 100% 105 | p.Success() 106 | 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://godoc.org/github.com/BTBurke/clt?status.svg)](http://godoc.org/github.com/BTBurke/clt) 2 | 3 | CLT - Command Line Tools for Go 4 | === 5 | CLT is a toolset for building elegant command line interfaces in Go. CLT includes elements like styled text, tables, selections from a list, and more so that you can quickly build CLIs with interactive elements without the hassle of dealing with formatting all these yourself. 6 | 7 | Go Doc documentation is available at [Godoc] and examples are located in the examples directory. This readme strives to show you the major features. 8 | 9 | ## Styled Text 10 | 11 | ```go 12 | package main 13 | 14 | import ( 15 | "fmt" 16 | "github.com/BTBurke/clt" 17 | ) 18 | 19 | func main() { 20 | fmt.Printf("This is %s text\n", clt.Styled(clt.Red).ApplyTo("red")) 21 | fmt.Printf("This is %s text\n", clt.SStyled("blue and underlined", clt.Blue, clt.Underline)) 22 | fmt.Printf("This is %s text\n", clt.SStyled("blue on a white background", clt.Blue, clt.Background(clt.White)) 23 | fmt.Printf("This is %s text\n", clt.Styled(clt.Italic).ApplyTo("italic")) 24 | fmt.Printf("This is %s text\n", clt.SStyled("bold", clt.Bold) 25 | } 26 | ``` 27 | ![console output](https://s3.amazonaws.com/btburke-github/styles_example.png) 28 | 29 | The general operation of the style function is to first call `clt.Styled(, , ...)`. This creates a style that can then be applied to a string via the `.ApplyTo()` method. A shortcut method `clt.SStyled("string", styles...)` can help eliminate some of the boilerplate. 30 | 31 | ## Tables 32 | 33 | CLT provides an easy-to-use library for building text tables. It provides layout algorithms for multi-column tables and the ability to style each column or individual cells using clt.Styled. 34 | 35 | Tables detect the terminal width and intelligently decide how cell contents should be wrapped to fit on screen. 36 | ```go 37 | package main 38 | 39 | import "github.com/BTBurke/clt" 40 | 41 | func main() { 42 | 43 | // Create a table with 3 columns 44 | t := clt.NewTable(5) 45 | 46 | // Add a title 47 | t.Title("Hockey Standings") 48 | 49 | // Set column headers 50 | t.ColumnHeaders("Team", "Points", "W", "L", "OT") 51 | 52 | // Add some rows 53 | t.AddRow("Washington Capitals", "42", "18", "11", "6") 54 | t.AddRow("NJ Devils", "31", "12", "18", "7") 55 | 56 | // Render the table 57 | t.Show() 58 | } 59 | ``` 60 | 61 | Produces: 62 | 63 | ![console output](https://s3.amazonaws.com/btburke-github/simple-table.png) 64 | 65 | #### More examples 66 | See [examples/table_example.go](https://github.com/BTBurke/clt/blob/master/examples/table_example.go) for more examples. Also, see the GoDoc for the details of the table library. 67 | 68 | ## Progress Bars 69 | 70 | CLT provides four kinds of progress indicators: 71 | 72 | * *Spinner* - Useful for when you want to show progress but don't know exactly when an action will complete 73 | 74 | * *Bar* - Useful when you have a defined number of iterations to completion and you can update progress during processing 75 | 76 | * *Incremental* - Userful when you have multiple go routines doing some work and you want to update total progress without each go routine having to synchronize state 77 | 78 | * *Loading* - Useful for when you are making a remote call and you want to give a visual indication that something is going on in the background, but you want it to disappear as soon as the call ends. It also has a configurable delay so that the loading indicator will only appear when the call takes longer than the delay to complete. 79 | 80 | #### Example: 81 | 82 | See [examples/progress_example.go](https://github.com/BTBurke/clt/blob/master/examples/progress_example.go) for the example in the screencast below. 83 | 84 | ![console output](https://s3.amazonaws.com/btburke-github/progress-ex-20171025.gif) 85 | 86 | Progress bars use go routines to update the progress status while your app does other processing. Remember to close out the progress element with either a call to `Success()` or `Fail()` to terminate this routine. 87 | 88 | ## Interactive Sessions 89 | 90 | CLT provides building blocks to create interactive sessions, giving you flexible functions to ask the user for input. 91 | 92 | See [examples/interactive_example.go](https://github.com/BTBurke/clt/blob/master/examples/interactive_example.go) for examples of creating interactive interfaces. 93 | 94 | #### Interactions 95 | 96 | | Interaction | Use | 97 | | ------- | ----------- | 98 | | Ask | Ask for a response with optional validation | 99 | | AskWithDefault | Ask with a preconfigured default value | 100 | | AskWithHint | Ask with a hint that shows how the input should be formatted | 101 | | AskPassword | Ask for a password without any echo to the terminal while the user types | 102 | | AskYesNo | Ask a yes or no question with a default to either | 103 | | AskFromTable | User picks an option from a table of possibilities | 104 | | Pause | Paginate some output with `Press [Enter] to continue.` | 105 | | PauseWithPrompt | Paginate some output with a custom prompt to continue | 106 | | Warn | Issue a warning that is visually separated from other text by style changes | 107 | | Error | Show an error message and exit the process | 108 | | Say | Thin wrapper around `fmt.Printf` that helps build interactive sessions in a fluent style and take care of common spacing issues | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /progress.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | const ( 13 | success int = iota 14 | fail 15 | ) 16 | 17 | const ( 18 | spinner int = iota 19 | bar 20 | loading 21 | ) 22 | 23 | // Spinner is a set of unicode strings that show a moving progress indication in the terminal 24 | type Spinner []string 25 | 26 | var ( 27 | // Wheel created with pipes and slashes 28 | Wheel Spinner = []string{"|", "/", "-", "\\"} 29 | // Bouncing dots 30 | Bouncing Spinner = []string{"⠁", "⠂", "⠄", "⠂"} 31 | // Clock that spins two hours per step 32 | Clock Spinner = []string{"🕐 ", "🕑 ", "🕒 ", "🕓 ", "🕔 ", "🕕 ", "🕖 ", "🕗 ", "🕘 ", "🕙 ", "🕚 "} 33 | // Dots that spin around a rectangle 34 | Dots Spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} 35 | ) 36 | 37 | // Progress structure used to render progress and loading indicators 38 | type Progress struct { 39 | // Prompt to display before spinner or bar 40 | Prompt string 41 | // Approximate length of the total progress display, including 42 | // the prompt and the ..., does not include status indicator 43 | // at the end (e.g, the spinner, FAIL, OK, or XX%) 44 | DisplayLength int 45 | 46 | style int 47 | cf chan float64 48 | c chan int 49 | steps int 50 | currentStep int 51 | spinsteps Spinner 52 | delay time.Duration 53 | output io.Writer 54 | wg sync.WaitGroup 55 | mu sync.Mutex 56 | } 57 | 58 | // NewProgressSpinner returns a new spinner with prompt 59 | // display length defaults to 30. 60 | func NewProgressSpinner(format string, args ...interface{}) *Progress { 61 | return &Progress{ 62 | style: spinner, 63 | Prompt: fmt.Sprintf(format, args...), 64 | DisplayLength: 30, 65 | output: os.Stdout, 66 | spinsteps: Wheel, 67 | } 68 | } 69 | 70 | // NewProgressBar returns a new progress bar with prompt 71 | // display length defaults to 20 72 | func NewProgressBar(format string, args ...interface{}) *Progress { 73 | return &Progress{ 74 | style: bar, 75 | Prompt: fmt.Sprintf(format, args...), 76 | DisplayLength: 20, 77 | output: os.Stdout, 78 | } 79 | } 80 | 81 | // NewIncrementalProgressBar returns a new progress bar with a fixed number of steps. This 82 | // can be useful when you want independent go routines to update total progress as they finish work 83 | // without knowing the total state of the system. You can call `defer mybar.Increment()` in your go 84 | // routine to update the value of the bar by one increment. 85 | func NewIncrementalProgressBar(steps int, format string, args ...interface{}) *Progress { 86 | return &Progress{ 87 | style: bar, 88 | Prompt: fmt.Sprintf(format, args...), 89 | DisplayLength: 20, 90 | output: os.Stdout, 91 | steps: steps, 92 | } 93 | } 94 | 95 | // NewLoadingMessage creates a spinning loading indicator followed by a message. 96 | // The loading indicator does not indicate sucess or failure and disappears when 97 | // you call either Success() or Failure(). This is useful to show action when 98 | // making remote calls that are expected to be short. The delay parameter is to 99 | // prevent flickering when the remote call finishes quickly. If you finish your call 100 | // and call Success() or Failure() within the delay period, the loading indicator 101 | // will never be shown. 102 | func NewLoadingMessage(message string, spinner Spinner, delay time.Duration) *Progress { 103 | return &Progress{ 104 | style: loading, 105 | Prompt: message, 106 | DisplayLength: 0, 107 | spinsteps: spinner, 108 | output: os.Stdout, 109 | delay: delay, 110 | } 111 | } 112 | 113 | // Start launches a Goroutine to render the progress bar or spinner 114 | // and returns control to the caller for further processing. Spinner 115 | // will update automatically every 250ms until Success() or Fail() is 116 | // called. Bars will update by calling Update(). You 117 | // must always finally call either Success() or Fail() to terminate 118 | // the go routine. 119 | func (p *Progress) Start() { 120 | p.wg.Add(1) 121 | switch p.style { 122 | case spinner: 123 | p.c = make(chan int) 124 | go renderSpinner(p, p.c) 125 | case bar: 126 | p.cf = make(chan float64, 2) 127 | go renderBar(p, p.cf) 128 | p.cf <- 0.0 129 | case loading: 130 | p.c = make(chan int) 131 | go renderLoading(p, p.c) 132 | } 133 | } 134 | 135 | // Success should be called on a progress bar or spinner 136 | // after completion is successful 137 | func (p *Progress) Success() { 138 | switch p.style { 139 | case spinner: 140 | p.c <- success 141 | close(p.c) 142 | case bar: 143 | p.cf <- -1.0 144 | close(p.cf) 145 | case loading: 146 | p.c <- success 147 | close(p.c) 148 | } 149 | p.wg.Wait() 150 | } 151 | 152 | // Fail should be called on a progress bar or spinner 153 | // if a failure occurs 154 | func (p *Progress) Fail() { 155 | switch p.style { 156 | case spinner: 157 | p.c <- fail 158 | close(p.c) 159 | case bar: 160 | p.cf <- -2.0 161 | close(p.cf) 162 | // loading only has one termination state 163 | case loading: 164 | p.c <- success 165 | close(p.c) 166 | } 167 | p.wg.Wait() 168 | } 169 | 170 | func renderSpinner(p *Progress, c chan int) { 171 | defer p.wg.Done() 172 | if p.output == nil { 173 | p.output = os.Stdout 174 | } 175 | promptLen := len(p.Prompt) 176 | dotLen := p.DisplayLength - promptLen 177 | if dotLen < 3 { 178 | dotLen = 3 179 | } 180 | for i := 0; ; i++ { 181 | select { 182 | case result := <-c: 183 | switch result { 184 | case success: 185 | fmt.Fprintf(p.output, "%s\r%s%s[%s]\n", showCursor, p.Prompt, strings.Repeat(".", dotLen), Styled(Green).ApplyTo("OK")) 186 | case fail: 187 | fmt.Fprintf(p.output, "%s\r%s%s[%s]\n", showCursor, p.Prompt, strings.Repeat(".", dotLen), Styled(Red).ApplyTo("FAIL")) 188 | } 189 | return 190 | default: 191 | fmt.Fprintf(p.output, "%s\r%s%s[%s]", hideCursor, p.Prompt, strings.Repeat(".", dotLen), spinLookup(i, p.spinsteps)) 192 | time.Sleep(time.Duration(250) * time.Millisecond) 193 | } 194 | } 195 | } 196 | 197 | func renderLoading(p *Progress, c chan int) { 198 | defer p.wg.Done() 199 | if p.output == nil { 200 | p.output = os.Stdout 201 | } 202 | 203 | // delay to prevent flickering 204 | // calling Success or Failure within delay will shortcircuit the loading indicator 205 | if p.delay > 0 { 206 | t := time.NewTicker(p.delay) 207 | select { 208 | case <-c: 209 | return 210 | case <-t.C: 211 | t.Stop() 212 | } 213 | } 214 | 215 | for i := 0; ; i++ { 216 | select { 217 | case <-c: 218 | fmt.Fprintf(p.output, "%s\r%s\r\n", hideCursor, strings.Repeat(" ", len(p.spinsteps[0])+len(p.Prompt)+3)) 219 | return 220 | default: 221 | fmt.Fprintf(p.output, "%s\r%s %s", hideCursor, spinLookup(i, p.spinsteps), p.Prompt) 222 | time.Sleep(time.Duration(250) * time.Millisecond) 223 | } 224 | } 225 | } 226 | 227 | func spinLookup(i int, steps []string) string { 228 | return steps[i%len(steps)] 229 | } 230 | 231 | func renderBar(p *Progress, c chan float64) { 232 | defer p.wg.Done() 233 | if p.output == nil { 234 | p.output = os.Stdout 235 | } 236 | 237 | for result := range c { 238 | eqLen := int(result * float64(p.DisplayLength)) 239 | spLen := p.DisplayLength - eqLen 240 | switch { 241 | case result == -1.0: 242 | fmt.Fprintf(p.output, "%s\r%s: [%s] %s", hideCursor, p.Prompt, strings.Repeat("=", p.DisplayLength), Styled(Green).ApplyTo("100%")) 243 | fmt.Fprintf(p.output, "%s\n", showCursor) 244 | return 245 | case result == -2.0: 246 | fmt.Fprintf(p.output, "%s\r%s: [%s] %s", hideCursor, p.Prompt, strings.Repeat("X", p.DisplayLength), Styled(Red).ApplyTo("FAIL")) 247 | fmt.Fprintf(p.output, "%s\n", showCursor) 248 | return 249 | case result >= 0.0: 250 | fmt.Fprintf(p.output, "%s\r%s: [%s%s] %2.0f%%", hideCursor, p.Prompt, strings.Repeat("=", eqLen), strings.Repeat(" ", spLen), 100.0*result) 251 | } 252 | 253 | } 254 | } 255 | 256 | // Update the progress bar using a number [0, 1.0] to represent 257 | // the percentage complete 258 | func (p *Progress) Update(pct float64) { 259 | if pct >= 1.0 { 260 | pct = 1.0 261 | } 262 | p.cf <- pct 263 | } 264 | 265 | // Increment updates a stepped progress bar 266 | func (p *Progress) Increment() { 267 | if p.steps == 0 { 268 | return 269 | } 270 | p.mu.Lock() 271 | defer p.mu.Unlock() 272 | 273 | p.currentStep += 1 274 | pct := float64(p.currentStep) / float64(p.steps) 275 | if pct >= 1.0 { 276 | pct = 1.0 277 | } 278 | p.cf <- pct 279 | } 280 | -------------------------------------------------------------------------------- /interactive.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "sort" 8 | 9 | "os" 10 | "strings" 11 | 12 | "golang.org/x/crypto/ssh/terminal" 13 | ) 14 | 15 | type getOpt int 16 | 17 | const ( 18 | noColon getOpt = iota 19 | ) 20 | 21 | // InteractiveSession creates a system for collecting user input 22 | // in response to questions and choices 23 | type InteractiveSession struct { 24 | Prompt string 25 | Default string 26 | ValHint string 27 | 28 | response string 29 | input *bufio.Reader 30 | output io.Writer 31 | } 32 | 33 | // NewInteractiveSession returns a new InteractiveSession outputting to Stdout 34 | // and reading from Stdin by default, but other inputs and outputs may be specified 35 | // with SessionOptions 36 | func NewInteractiveSession(opts ...SessionOption) *InteractiveSession { 37 | i := &InteractiveSession{ 38 | input: bufio.NewReader(os.Stdin), 39 | output: os.Stdout, 40 | } 41 | for _, opt := range opts { 42 | opt(i) 43 | } 44 | return i 45 | } 46 | 47 | // SessionOption optionally configures aspects of the interactive session 48 | type SessionOption func(i *InteractiveSession) 49 | 50 | // WithInput uses an input other than os.Stdin 51 | func WithInput(r io.Reader) SessionOption { 52 | return func(i *InteractiveSession) { 53 | i.input = bufio.NewReader(r) 54 | } 55 | } 56 | 57 | // WithOutput uses an output other than os.Stdout 58 | func WithOutput(w io.Writer) SessionOption { 59 | return func(i *InteractiveSession) { 60 | i.output = w 61 | } 62 | } 63 | 64 | // Reset allows reuse of the same interactive session by reseting its state and keeping 65 | // its current input and output 66 | func (i *InteractiveSession) Reset() { 67 | i.Prompt = "" 68 | i.Default = "" 69 | i.ValHint = "" 70 | i.response = "" 71 | } 72 | 73 | // Say is a short form of fmt.Fprintf but allows you to chain additional terminators to 74 | // the interactive session to collect user input 75 | func (i *InteractiveSession) Say(format string, args ...interface{}) *InteractiveSession { 76 | fmt.Fprintf(i.output, fmt.Sprintf("\n%s\n", format), args...) 77 | return i 78 | } 79 | 80 | // Pause is a terminator that will render long-form text added via the another method 81 | // that returns *InteractiveSession and will wait for the user to press enter to continue. 82 | // It is useful for long-form content or paging. 83 | func (i *InteractiveSession) Pause() { 84 | i.Prompt = "\nPress [Enter] to continue." 85 | i.get(noColon) 86 | } 87 | 88 | // PauseWithPrompt is a terminator that will render long-form text added via the another method 89 | // that returns *InteractiveSession and will wait for the user to press enter to continue. 90 | // This will use the custom prompt specified by format and args. 91 | func (i *InteractiveSession) PauseWithPrompt(format string, args ...interface{}) { 92 | i.Prompt = fmt.Sprintf(format, args...) 93 | i.get(noColon) 94 | } 95 | 96 | func (i *InteractiveSession) get(opts ...getOpt) (err error) { 97 | contains := func(wanted getOpt) bool { 98 | for _, opt := range opts { 99 | if opt == wanted { 100 | return true 101 | } 102 | } 103 | return false 104 | } 105 | 106 | if i.output == nil { 107 | i.output = bufio.NewWriter(os.Stdout) 108 | } 109 | if i.input == nil { 110 | i.input = bufio.NewReader(os.Stdin) 111 | } 112 | 113 | switch { 114 | case len(i.Default) > 0: 115 | fmt.Fprintf(i.output, "%s [%s]: ", i.Prompt, i.Default) 116 | case len(i.ValHint) > 0: 117 | fmt.Fprintf(i.output, "%s (%s): ", i.Prompt, i.ValHint) 118 | case contains(noColon): 119 | fmt.Fprintf(i.output, "%s", i.Prompt) 120 | case len(i.Prompt) > 0: 121 | fmt.Fprintf(i.output, "%s: ", i.Prompt) 122 | default: 123 | } 124 | 125 | i.response, err = i.input.ReadString('\n') 126 | if err != nil { 127 | return err 128 | } 129 | i.response = strings.TrimRight(i.response, " \n\r") 130 | if len(i.Default) > 0 && len(i.response) == 0 { 131 | switch i.Default { 132 | case "y/N": 133 | i.response = "n" 134 | case "Y/n": 135 | i.response = "y" 136 | default: 137 | i.response = i.Default 138 | } 139 | } 140 | 141 | return nil 142 | } 143 | 144 | // Warn adds an informational warning message to the user in format 145 | // Warning: 146 | func (i *InteractiveSession) Warn(format string, args ...interface{}) *InteractiveSession { 147 | fmt.Fprintf(i.output, "\n%s: %s\n", Styled(Yellow).ApplyTo("Warning"), fmt.Sprintf(format, args...)) 148 | return i 149 | } 150 | 151 | // Error is a terminator that gives an informational error message to the user in format 152 | // Error: . Exits the program returning status code 1 153 | func (i *InteractiveSession) Error(format string, args ...interface{}) { 154 | fmt.Fprintf(i.output, "\n\n%s: %s\n", Styled(Red).ApplyTo("Error:"), fmt.Sprintf(format, args...)) 155 | os.Exit(1) 156 | } 157 | 158 | // Ask is a terminator for an interactive session that results in returning the user's 159 | // input. Validators can optionally be applied to ensure that acceptable input is returned 160 | // or the question will be asked again. 161 | func (i *InteractiveSession) Ask(prompt string, validators ...ValidationFunc) string { 162 | return i.ask(prompt, "", "", validators...) 163 | } 164 | 165 | // AskWithDefault is like ask, but sets a default choice that the user can select by pressing enter. 166 | func (i *InteractiveSession) AskWithDefault(prompt string, defaultChoice string, validators ...ValidationFunc) string { 167 | return i.ask(prompt, defaultChoice, "", validators...) 168 | } 169 | 170 | // AskWithHint is like ask, but gives a hint about the proper format of the response. This is useful 171 | // combined with a validation function to get input in the right format. 172 | func (i *InteractiveSession) AskWithHint(prompt string, hint string, validators ...ValidationFunc) string { 173 | return i.ask(prompt, "", hint, validators...) 174 | } 175 | 176 | func (i *InteractiveSession) ask(prompt string, def string, hint string, validators ...ValidationFunc) string { 177 | i.Prompt = prompt 178 | i.Default = def 179 | i.ValHint = hint 180 | i.get() 181 | for _, validator := range validators { 182 | if ok, err := validator(i.response); !ok { 183 | i.Say("\nError: %s\n\n", err) 184 | i.ask(prompt, def, hint, validators...) 185 | } 186 | } 187 | return i.response 188 | } 189 | 190 | // AskPassword is a terminator that asks for a password and does not echo input 191 | // to the terminal. 192 | func (i *InteractiveSession) AskPassword(validators ...ValidationFunc) string { 193 | return askPassword(i, "Password: ", validators...) 194 | } 195 | 196 | // AskPasswordPrompt is a terminator that asks for a password with a custom prompt 197 | func (i *InteractiveSession) AskPasswordPrompt(prompt string, validators ...ValidationFunc) string { 198 | return askPassword(i, prompt, validators...) 199 | } 200 | 201 | func askPassword(i *InteractiveSession, prompt string, validators ...ValidationFunc) string { 202 | fmt.Fprintf(i.output, "Password: ") 203 | pw, err := terminal.ReadPassword(0) 204 | if err != nil { 205 | i.Error("\n%s\n", err) 206 | } 207 | 208 | pwS := strings.TrimSpace(string(pw)) 209 | for _, validator := range validators { 210 | if ok, err := validator(pwS); !ok { 211 | i.Say("\nError: %s\n\n", err) 212 | i.AskPassword(validators...) 213 | } 214 | } 215 | return pwS 216 | } 217 | 218 | // AskYesNo asks the user a yes or no question with a default value. Defaults of `y` or `yes` will 219 | // set the default to yes. Anything else will default to no. You can use IsYes or IsNo to act on the response 220 | // without worrying about what version of y, Y, YES, yes, etc. that the user entered. 221 | func (i *InteractiveSession) AskYesNo(prompt string, defaultChoice string) string { 222 | switch def := strings.ToLower(defaultChoice); def { 223 | case "y", "yes": 224 | i.Default = "Y/n" 225 | default: 226 | i.Default = "y/N" 227 | } 228 | return i.ask(prompt, i.Default, "", ValidateYesNo()) 229 | } 230 | 231 | // AskFromTable creates a table to select choices from. It has a built-in validation function that will 232 | // ensure that only the options listed are valid choices. 233 | func (i *InteractiveSession) AskFromTable(prompt string, choices map[string]string, def string) string { 234 | t := NewTable(2). 235 | ColumnHeaders("Option", "") 236 | var allKeys []string 237 | for key := range choices { 238 | allKeys = append(allKeys, key) 239 | } 240 | sort.Strings(allKeys) 241 | 242 | for _, key := range allKeys { 243 | t.AddRow(key, choices[key]) 244 | } 245 | tAsString := t.AsString() 246 | 247 | i.Prompt = fmt.Sprintf("\n%s%s\nChoice", prompt, tAsString) 248 | i.Default = def 249 | i.get() 250 | if ok, err := AllowedOptions(allKeys)(i.response); !ok { 251 | i.Say("\nError: %s\n\n", err) 252 | i.AskFromTable(prompt, choices, def) 253 | } 254 | 255 | return strings.TrimSpace(i.response) 256 | } 257 | -------------------------------------------------------------------------------- /table_test.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/BTBurke/snapshot" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestTerminalSizeCheck(t *testing.T) { 14 | t.Skipf("Terminal check skipped. No TTY.") 15 | h, w, err := getTerminalSize() 16 | if err != nil || h == -1 || w == -1 { 17 | fmt.Printf("Cannot determine terminal size for Table. This will still work, but will not be able to automagically determine sizes.") 18 | } 19 | } 20 | 21 | func TestCreateTable(t *testing.T) { 22 | table := NewTable(3) 23 | if len(table.columns) != 3 { 24 | t.Errorf("Table should have %d columns, has %d.", 3, len(table.columns)) 25 | } 26 | for _, col := range table.columns { 27 | if !reflect.DeepEqual(col.justify, Left) { 28 | t.Errorf("Table default justification should be %v, got %v.", Left, col.justify) 29 | } 30 | } 31 | if table.maxHeight == 0 || table.maxWidth == 0 { 32 | t.Error("Table should have a width/height.") 33 | } 34 | 35 | } 36 | 37 | func TestBasicAddRow(t *testing.T) { 38 | want := []string{"test1", "test2"} 39 | table := NewTable(len(want)) 40 | table.AddRow(want...) 41 | if len(table.rows) != 1 { 42 | t.Errorf("Table should have %d rows, has %d.", len(want), len(table.rows)) 43 | } 44 | for idx := range want { 45 | gotX := table.rows[0].cells[idx].value 46 | if gotX != want[idx] { 47 | t.Errorf("Table cell 1 should be %s, got %s.", want[idx], gotX) 48 | } 49 | } 50 | } 51 | 52 | func TestShortAddRow(t *testing.T) { 53 | want := []string{"test1", "test2"} 54 | table := NewTable(len(want) + 1) 55 | table.AddRow(want...) 56 | gotEmpty := table.rows[0].cells[2].value 57 | if gotEmpty != "" { 58 | t.Errorf("Table cell 2 should be empty, got %s.", gotEmpty) 59 | } 60 | } 61 | 62 | func TestCellLength(t *testing.T) { 63 | want := []string{"---7---", "---8----"} 64 | table := NewTable(len(want)) 65 | table.AddRow(want...) 66 | for idx := range want { 67 | lenX := table.rows[0].cells[idx].width 68 | if lenX != len(want[idx]) { 69 | t.Errorf("Table cell length should be %v, got %v.", len(want[idx]), lenX) 70 | } 71 | } 72 | } 73 | 74 | func TestTitle(t *testing.T) { 75 | want := "This is the title" 76 | table := NewTable(1) 77 | table.Title(want) 78 | if table.title.value != want { 79 | t.Errorf("Title should be %s, got %s", want, table.title.value) 80 | } 81 | if table.title.width != len(want) { 82 | t.Errorf("Title length should be %v, got %v.", len(want), table.title.width) 83 | } 84 | assert.Equal(t, table.title.style, Styled(Bold)) 85 | } 86 | 87 | func TestSetColumnHeaders(t *testing.T) { 88 | table := NewTable(2) 89 | table.ColumnHeaders("Header1", "Header2") 90 | want := []Cell{Cell{ 91 | value: "Header1", 92 | style: Styled(Bold, Underline), 93 | width: len("Header1"), 94 | }, 95 | Cell{ 96 | value: "Header2", 97 | style: Styled(Bold, Underline), 98 | width: len("Header2"), 99 | }, 100 | } 101 | for i, header := range table.headers { 102 | if header.value != want[i].value { 103 | t.Errorf("Header should be %s, got %s", want[i].value, header.value) 104 | } 105 | } 106 | } 107 | 108 | func TestRenderHelpers(t *testing.T) { 109 | n := []int{1, 3, 2} 110 | table := &Table{} 111 | table.columns = []Col{Col{ 112 | naturalWidth: 10, 113 | computedWidth: 12, 114 | }, 115 | Col{ 116 | naturalWidth: 12, 117 | computedWidth: 14, 118 | }, 119 | } 120 | table.pad = 2 121 | 122 | assert.Equal(t, mapAdd(n, 1), []int{2, 4, 3}) 123 | assert.Equal(t, sum(n), 6) 124 | i, m := max(n) 125 | assert.Equal(t, m, 3) 126 | assert.Equal(t, i, 1) 127 | assert.Equal(t, sumWithoutIndex(n, 1), 3) 128 | assert.True(t, wrappedWidthOk(51, 100)) 129 | assert.False(t, wrappedWidthOk(49, 100)) 130 | assert.Equal(t, extractComputedWidth(table), []int{12, 14}) 131 | assert.Equal(t, extractNatWidth(table), []int{10, 12}) 132 | assert.Equal(t, table.width(), 12+14+8) 133 | 134 | } 135 | 136 | // test helper to get string of length n 137 | func s(n int) string { 138 | return strings.Repeat("x", n) 139 | } 140 | 141 | func TestSimpleStrategy(t *testing.T) { 142 | table := NewTable(3) 143 | table.maxWidth = 80 144 | table.ColumnHeaders(s(4), s(4), s(4)) 145 | table.AddRow(s(10), s(12), s(14)) 146 | table.pad = 2 147 | table.computeColWidths() 148 | t.Run("NaturalColWidths < maxWidth", func(t *testing.T) { 149 | assert.Equal(t, extractNatWidth(table), []int{10, 12, 14}) 150 | assert.Equal(t, extractComputedWidth(table), []int{10, 12, 14}) 151 | }) 152 | 153 | // headers bigger than content 154 | table.ColumnHeaders(s(15), s(16), s(17)) 155 | table.computeColWidths() 156 | t.Run("Big headers, NaturalWidth < maxWidth", func(t *testing.T) { 157 | assert.Equal(t, extractNatWidth(table), []int{15, 16, 17}) 158 | assert.Equal(t, extractComputedWidth(table), []int{15, 16, 17}) 159 | }) 160 | } 161 | 162 | func TestWrapWidest(t *testing.T) { 163 | table := NewTable(3) 164 | table.maxWidth = 60 165 | table.AddRow(s(10), s(20), s(40)) 166 | 167 | t.Run("Last column wrap, no padding", func(t *testing.T) { 168 | table.pad = 0 169 | table.computeColWidths() 170 | assert.Equal(t, extractNatWidth(table), []int{10, 20, 40}) 171 | assert.Equal(t, extractComputedWidth(table), []int{10, 20, 30}) 172 | }) 173 | 174 | } 175 | 176 | func TestOverflow(t *testing.T) { 177 | table := NewTable(3) 178 | table.maxWidth = 10 179 | table.AddRow(s(10), s(20), s(40)) 180 | 181 | t.Run("Overflow to natural width as last resort", func(t *testing.T) { 182 | table.pad = 0 183 | table.computeColWidths() 184 | assert.Equal(t, extractNatWidth(table), []int{10, 20, 40}) 185 | assert.Equal(t, extractComputedWidth(table), []int{10, 20, 40}) 186 | }) 187 | 188 | } 189 | 190 | func TestJustifcation(t *testing.T) { 191 | s := s(4) 192 | t.Run("Center justify text with padding", func(t *testing.T) { 193 | width := 14 194 | pad := 2 195 | sty := Styled(Default) 196 | want := fmt.Sprintf(" %s ", sty.ApplyTo(s)) 197 | assert.Equal(t, justCenter(s, width, pad, sty), want) 198 | }) 199 | t.Run("Center justify offest left on uneven", func(t *testing.T) { 200 | width := 13 201 | pad := 2 202 | sty := Styled(Default) 203 | want := fmt.Sprintf(" %s ", sty.ApplyTo(s)) 204 | assert.Equal(t, justCenter(s, width, pad, sty), want) 205 | }) 206 | t.Run("Left justify text with padding", func(t *testing.T) { 207 | width := 8 208 | pad := 2 209 | sty := Styled(Default) 210 | want := fmt.Sprintf(" %s ", sty.ApplyTo(s)) 211 | assert.Equal(t, justLeft(s, width, pad, sty), want) 212 | }) 213 | t.Run("Right justify text with padding", func(t *testing.T) { 214 | width := 8 215 | pad := 2 216 | sty := Styled(Default) 217 | want := fmt.Sprintf(" %s ", sty.ApplyTo(s)) 218 | assert.Equal(t, justRight(s, width, pad, sty), want) 219 | }) 220 | t.Run("Fallback to string + padding if widths jacked up", func(t *testing.T) { 221 | width := 1 222 | pad := 2 223 | sty := Styled(Default) 224 | want := fmt.Sprintf(" %s ", sty.ApplyTo(s)) 225 | assert.Equal(t, justCenter(s, width, pad, sty), want) 226 | assert.Equal(t, justLeft(s, width, pad, sty), want) 227 | assert.Equal(t, justRight(s, width, pad, sty), want) 228 | }) 229 | 230 | } 231 | 232 | func TestRenderTitle(t *testing.T) { 233 | table := NewTable(2) 234 | table.AddRow(s(10), s(10)) 235 | table.pad = 0 236 | table.maxWidth = 30 237 | table.Title("Test Title") 238 | table.computeColWidths() 239 | want := fmt.Sprintf(" %s ", Styled(Bold).ApplyTo("Test Title")) 240 | t.Run("Title should be bold and centered", func(t *testing.T) { 241 | assert.Equal(t, renderTitle(table), want) 242 | }) 243 | } 244 | 245 | func TestRenderCell(t *testing.T) { 246 | table := NewTable(1) 247 | table.AddRow(s(10)) 248 | table.AddRow(s(14)) 249 | table.maxWidth = 30 250 | table.pad = 2 251 | table.computeColWidths() 252 | t.Run("Cell should be rendered with correct justification", func(t *testing.T) { 253 | want := fmt.Sprintf(" %s ", Styled(Default).ApplyTo(s(10))) 254 | st := renderCell(table.rows[0].cells[0].value, table.columns[0].computedWidth, table.pad, table.columns[0].style, table.columns[0].justify) 255 | assert.Equal(t, st, want) 256 | table.columns[0].justify = Center 257 | want = fmt.Sprintf(" %s ", Styled(Default).ApplyTo(s(10))) 258 | st = renderCell(table.rows[0].cells[0].value, table.columns[0].computedWidth, table.pad, table.columns[0].style, table.columns[0].justify) 259 | assert.Equal(t, st, want) 260 | table.columns[0].justify = Right 261 | want = fmt.Sprintf(" %s ", Styled(Default).ApplyTo(s(10))) 262 | st = renderCell(table.rows[0].cells[0].value, table.columns[0].computedWidth, table.pad, table.columns[0].style, table.columns[0].justify) 263 | assert.Equal(t, st, want) 264 | }) 265 | } 266 | 267 | func TestRenderRow(t *testing.T) { 268 | table := NewTable(2) 269 | table.AddRow(s(10), s(10)) 270 | table.AddRow(s(10), s(20)) 271 | table.pad = 2 272 | table.maxWidth = 28 273 | table.computeColWidths() 274 | c10 := Styled(Default).ApplyTo(s(10)) 275 | cEmpty := Styled(Default).ApplyTo("") 276 | t.Run("Non-wrapped row rendered normally", func(t *testing.T) { 277 | 278 | want := fmt.Sprintf(" %s %s \n", c10, c10) 279 | renderedRow := renderRow(table.rows[0].cells, table.columns, table.pad, table.spacing) 280 | assert.Equal(t, renderedRow, want) 281 | }) 282 | t.Run("Wrapped row rendered as multiple lines", func(t *testing.T) { 283 | want := fmt.Sprintf(" %s %s \n %s %s \n", c10, c10, cEmpty, c10) 284 | renderedRow := renderRow(table.rows[1].cells, table.columns, table.pad, table.spacing) 285 | assert.Equal(t, renderedRow, want) 286 | }) 287 | } 288 | 289 | func TestRenderTable(t *testing.T) { 290 | table := NewTable(2) 291 | table.AddRow(s(10), s(10)) 292 | table.AddRow(s(10), s(20)) 293 | table.pad = 2 294 | table.maxWidth = 28 295 | table.Title("Test Table") 296 | c10 := Styled(Default).ApplyTo(s(10)) 297 | cEmpty := Styled(Default).ApplyTo("") 298 | cTitle := Styled(Bold).ApplyTo("Test Table") 299 | t.Run("Table with wrapped + non-wrapped rows rendered appropriately", func(t *testing.T) { 300 | want0 := fmt.Sprintf(" %s \n\n", cTitle) 301 | want1 := fmt.Sprintf(" %s %s \n", c10, c10) 302 | want2 := fmt.Sprintf(" %s %s \n %s %s \n", c10, c10, cEmpty, c10) 303 | want := want0 + want1 + want2 304 | renderedTable := table.AsString() 305 | assert.Equal(t, renderedTable, want) 306 | }) 307 | } 308 | 309 | func TestHeadersShort(t *testing.T) { 310 | table := NewTable(2). 311 | ColumnHeaders("test") 312 | t.Run("Table with only one column header set", func(t *testing.T) { 313 | assert.Equal(t, table.headers[0].value, "test") 314 | assert.Equal(t, table.headers[1].value, "") 315 | snapshot.Assert(t, []byte(table.AsString())) 316 | }) 317 | } 318 | -------------------------------------------------------------------------------- /table.go: -------------------------------------------------------------------------------- 1 | package clt 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | // Justification sets the default placement of text inside each cell of a column 13 | type Justification int 14 | 15 | // Column justification flags 16 | const ( 17 | Left Justification = iota 18 | Center 19 | Right 20 | ) 21 | 22 | // Cell represents a cell in the table. Most often you'll create a cell using StyledCell 23 | // in conjuction with AddStyledRow 24 | type Cell struct { 25 | value string 26 | width int 27 | style *Style 28 | } 29 | 30 | // Title is a special cell that is rendered at the center top of the table that can contain 31 | // its own styling. 32 | type Title struct { 33 | value string 34 | width int 35 | style *Style 36 | } 37 | 38 | // Row is a row of cells in a table. You want to use AddRow or AddStyledRow to create one. 39 | type Row struct { 40 | cells []Cell 41 | } 42 | 43 | // Col is a column of a table. Use ColumnHeaders, ColumnStyles, etc. to adjust default 44 | // styling and header properties. You can always override a particular cell in a column 45 | // by passing in a different Cell style when you AddStyledRow. 46 | type Col struct { 47 | index int 48 | naturalWidth int 49 | computedWidth int 50 | wrap bool 51 | style *Style 52 | justify Justification 53 | } 54 | 55 | // Table is a table output to the console. Use NewTable to construct the table with sensible defaults. 56 | // Tables detect the terminal width and step through a number of rendering strategies to intelligently 57 | // wrap column information to fit within the available space. 58 | type Table struct { 59 | title Title 60 | columns []Col 61 | headers []Cell 62 | rows []Row 63 | pad int 64 | maxWidth int 65 | maxHeight int 66 | spacing int 67 | 68 | writer io.Writer 69 | } 70 | 71 | // TableOption is a function that sets an option on a table 72 | type TableOption func(t *Table) error 73 | 74 | // MaxHeight sets the table maximum height that can be used for pagination of 75 | // long tables. Use this only when you want to reduce the maximum height of the table 76 | // to something less than the detected height of the terminal. Normally you don't need to use 77 | // this and should prefer the auto detection. 78 | func MaxHeight(h int) TableOption { 79 | return func(t *Table) error { 80 | t.maxHeight = h 81 | return nil 82 | } 83 | } 84 | 85 | // MaxWidth sets the max width of the table. The actual max width will be set to the 86 | // smaller of this number of the detected width of the terminal. Very small max widths can 87 | // be a problem because the layout engine will not be able to find a strategy to render the 88 | // table. 89 | func MaxWidth(w int) TableOption { 90 | return func(t *Table) error { 91 | if t.maxWidth > w { 92 | t.maxWidth = w 93 | } 94 | return nil 95 | } 96 | } 97 | 98 | // Spacing adjusts the spacing of the table. For n=2, there will be one whitespace line 99 | // between each row 100 | func Spacing(n int) TableOption { 101 | return func(t *Table) error { 102 | switch { 103 | case n > 1: 104 | t.spacing = n 105 | default: 106 | t.spacing = 1 107 | } 108 | return nil 109 | } 110 | } 111 | 112 | func (r *Row) addCell(c Cell) { 113 | r.cells = append(r.cells, c) 114 | } 115 | 116 | // AddRow adds a new row to the table given an array of strings for each column's 117 | // content. You can set styles on a row by using AddStyledRow instead. If you add more cells 118 | // than available columns, the cells will be silently truncated. If there are fewer values than columns, 119 | // the remaining columns will be empty. 120 | func (t *Table) AddRow(rowStrings ...string) *Table { 121 | newRow := Row{} 122 | for i, rValue := range rowStrings { 123 | if i >= len(t.columns) { 124 | break 125 | } 126 | newRow.addCell(Cell{value: rValue, width: len(rValue), style: t.columns[i].style}) 127 | } 128 | for len(newRow.cells) < len(t.columns) { 129 | newRow.addCell(Cell{value: "", width: 0, style: Styled(Default)}) 130 | } 131 | t.rows = append(t.rows, newRow) 132 | return t 133 | } 134 | 135 | // AddStyledRow adds a new row to the table with custom styles for each Cell. If you add more cells 136 | // than available columns, the cells will be silently truncated. If there are fewer values than columns, 137 | // the remaining columns will be empty. 138 | func (t *Table) AddStyledRow(cells ...Cell) *Table { 139 | newRow := Row{} 140 | for i, cell1 := range cells { 141 | if i >= len(t.columns) { 142 | break 143 | } 144 | newRow.addCell(cell1) 145 | } 146 | for len(newRow.cells) < len(t.columns) { 147 | newRow.addCell(Cell{value: "", width: 0, style: Styled(Default)}) 148 | } 149 | t.rows = append(t.rows, newRow) 150 | return t 151 | } 152 | 153 | // StyledCell returns a new cell with a custom style for use with AddStyledRow 154 | func StyledCell(v string, sty *Style) Cell { 155 | return Cell{value: v, width: len(v), style: sty} 156 | } 157 | 158 | // ColumnStyles sets the default styles for each column in the row except 159 | // the column headers. 160 | func (t *Table) ColumnStyles(styles ...*Style) *Table { 161 | for i, sty := range styles { 162 | if i >= len(t.columns) { 163 | return t 164 | } 165 | t.columns[i].style = sty 166 | } 167 | return t 168 | } 169 | 170 | // Title sets the title for the table. The default style is bold, but can 171 | // be changed by passing your own styles 172 | func (t *Table) Title(s string, styles ...Styler) *Table { 173 | var sty *Style 174 | switch { 175 | case len(styles) > 0: 176 | sty = Styled(styles...) 177 | default: 178 | sty = Styled(Bold) 179 | } 180 | t.title = Title{value: s, width: len(s), style: sty} 181 | return t 182 | } 183 | 184 | // ColumnHeaders sets the column headers with an array of strings 185 | // The default style is Underline and Bold. This can be changed through 186 | // a call to ColumnHeaderStyles. 187 | func (t *Table) ColumnHeaders(headers ...string) *Table { 188 | for i, header := range headers { 189 | if i >= len(t.columns) { 190 | return t 191 | } 192 | t.headers[i].value = header 193 | t.headers[i].style = Styled(Bold, Underline) 194 | t.headers[i].width = len(header) 195 | } 196 | return t 197 | } 198 | 199 | // ColumnHeaderStyles sets the column header styles. 200 | func (t *Table) ColumnHeaderStyles(styles ...*Style) *Table { 201 | for i, style := range styles { 202 | if i > len(t.columns) { 203 | return t 204 | } 205 | t.headers[i].style = style 206 | } 207 | return t 208 | } 209 | 210 | // Justification sets the justification of each column. If you pass more justifications 211 | // than the number of columns they will be silently dropped. 212 | func (t *Table) Justification(cellJustifications ...Justification) *Table { 213 | for i, just := range cellJustifications { 214 | if i > len(t.columns) { 215 | return t 216 | } 217 | t.columns[i].justify = just 218 | } 219 | return t 220 | } 221 | 222 | // NewTable creates a new table with a given number of columns, setting the default 223 | // justfication to left, and attempting to detect the existing terminal size to 224 | // set size defaults. 225 | func NewTable(numColumns int, options ...TableOption) *Table { 226 | w, h, err := getTerminalSize() 227 | if err != nil || w == 0 || h == 0 { 228 | w = 80 229 | h = 25 230 | } 231 | 232 | // Fill with defaults to skip complicated bounds checking on 233 | // changing justify or row styles 234 | defaultColumns := make([]Col, numColumns) 235 | emptyHeaders := make([]Cell, numColumns) 236 | for i := 0; i < numColumns; i++ { 237 | defaultColumns[i].index = i 238 | defaultColumns[i].style = Styled(Default) 239 | defaultColumns[i].justify = Left 240 | defaultColumns[i].wrap = false 241 | } 242 | 243 | t := &Table{ 244 | columns: defaultColumns, 245 | maxWidth: w, 246 | maxHeight: h, 247 | headers: emptyHeaders, 248 | pad: 1, 249 | title: Title{value: "", width: 0, style: Styled(Default)}, 250 | writer: os.Stdout, 251 | } 252 | for _, opt := range options { 253 | opt(t) 254 | } 255 | return t 256 | } 257 | 258 | // Show will render the table using the headers, title, and styles previously 259 | // set. 260 | func (t *Table) Show() { 261 | tableAsString := t.AsString() 262 | fmt.Fprintf(t.writer, tableAsString) 263 | 264 | } 265 | 266 | // ShowPage will render the table but pauses every n rows to paginate the output. 267 | // If n=0, it will use the detected terminal height to make sure that the number of rows 268 | // shown will fit in a single page. 269 | func (t *Table) ShowPage(n int) { 270 | if n == 0 { 271 | n = t.maxHeight - 3 272 | } 273 | tableAsString := t.AsString() 274 | lines := strings.SplitAfter(tableAsString, "\n") 275 | sess := NewInteractiveSession() 276 | 277 | start := 1 278 | for i := range lines { 279 | switch { 280 | case i > 0 && i%n == 0: 281 | fmt.Fprintf(t.writer, lines[i]) 282 | sess.PauseWithPrompt("\nResults %d-%d of %d. Press [Enter] to continue.\n", start, i+1, len(lines)) 283 | start = i + 2 284 | default: 285 | fmt.Fprintf(t.writer, lines[i]) 286 | } 287 | } 288 | } 289 | 290 | // SetWriter sets the output writer if not writing to Stdout 291 | func (t *Table) SetWriter(w io.Writer) { 292 | t.writer = w 293 | } 294 | 295 | // AsString returns the rendered table as a string instead of immediately writing to the configured writer 296 | func (t *Table) AsString() string { 297 | err := t.computeColWidths() 298 | if err != nil { 299 | // this error should never happen with fallback overflow strategy 300 | log.Fatal(err) 301 | } 302 | var renderedT bytes.Buffer 303 | renderedT.WriteString(renderTitle(t) + "\n\n") 304 | renderedT.WriteString(renderHeaders(t.headers, t.columns, t.pad)) 305 | for _, row := range t.rows { 306 | renderedT.WriteString(renderRow(row.cells, t.columns, t.pad, t.spacing)) 307 | } 308 | return renderedT.String() 309 | } 310 | 311 | // renderTitle returns the title as a formatted string 312 | func renderTitle(t *Table) string { 313 | return justCenter(t.title.value, t.width(), 0, t.title.style) 314 | } 315 | 316 | // renders the headers as a string 317 | func renderHeaders(cells []Cell, cols []Col, pad int) string { 318 | wrappedLinesCount := make([]int, len(cells)) 319 | 320 | for i, cell1 := range cells { 321 | wrappedL := wrap(cell1.value, cols[i].computedWidth) 322 | wrappedLinesCount[i] = len(wrappedL) 323 | } 324 | _, totalLines := max(wrappedLinesCount) 325 | lines := make([]bytes.Buffer, totalLines) 326 | 327 | for cellN, cellV := range cells { 328 | wL := wrap(cellV.value, cols[cellN].computedWidth) 329 | for i := 0; i < totalLines; i++ { 330 | switch { 331 | case i < len(wL): 332 | lines[i].WriteString(renderCell(wL[i], cols[cellN].computedWidth, pad, cellV.style, cols[cellN].justify)) 333 | default: 334 | lines[i].WriteString(renderCell("", cols[cellN].computedWidth, pad, cellV.style, cols[cellN].justify)) 335 | } 336 | } 337 | } 338 | var out bytes.Buffer 339 | for _, line := range lines { 340 | out.Write(line.Bytes()) 341 | out.WriteString("\n") 342 | } 343 | return out.String() 344 | } 345 | 346 | // renderRow renders the row as a styled string and implements the 347 | // wrapping of long strings where necessary 348 | func renderRow(cells []Cell, cols []Col, pad int, spacing int) string { 349 | wrappedLinesCount := make([]int, len(cells)) 350 | 351 | for i, cell1 := range cells { 352 | wrappedL := wrap(cell1.value, cols[i].computedWidth) 353 | wrappedLinesCount[i] = len(wrappedL) 354 | } 355 | _, totalLines := max(wrappedLinesCount) 356 | lines := make([]bytes.Buffer, totalLines) 357 | 358 | for cellN, cellV := range cells { 359 | // override column style with cell style if different 360 | var sty *Style 361 | switch { 362 | case cellV.style != cols[cellN].style: 363 | sty = cellV.style 364 | default: 365 | sty = cols[cellN].style 366 | } 367 | 368 | wL := wrap(cellV.value, cols[cellN].computedWidth) 369 | for i := 0; i < totalLines; i++ { 370 | switch { 371 | case i < len(wL): 372 | lines[i].WriteString(renderCell(wL[i], cols[cellN].computedWidth, pad, sty, cols[cellN].justify)) 373 | default: 374 | lines[i].WriteString(renderCell("", cols[cellN].computedWidth, pad, sty, cols[cellN].justify)) 375 | } 376 | } 377 | } 378 | var out bytes.Buffer 379 | for _, line := range lines { 380 | out.Write(line.Bytes()) 381 | out.WriteString("\n") 382 | } 383 | if spacing > 1 { 384 | out.WriteString(strings.Repeat("\n", spacing-1)) 385 | } 386 | return out.String() 387 | } 388 | 389 | // renderCell renders the cell as a string using the correct justification 390 | func renderCell(s string, width int, pad int, sty *Style, justify Justification) string { 391 | switch justify { 392 | case Left: 393 | return justLeft(s, width, pad, sty) 394 | case Center: 395 | return justCenter(s, width, pad, sty) 396 | case Right: 397 | return justRight(s, width, pad, sty) 398 | } 399 | return "" 400 | } 401 | 402 | // justCenter is center-justified text with padding and style 403 | func justCenter(s string, width int, pad int, sty *Style) string { 404 | contentLen := len(s) 405 | onLeft := (width - contentLen) / 2 406 | if onLeft < 0 { 407 | onLeft = 0 408 | } 409 | onRight := width - contentLen - onLeft 410 | if onRight < 0 { 411 | onRight = 0 412 | } 413 | switch { 414 | case sty == nil: 415 | return fmt.Sprintf("%s%s%s", spaces(onLeft+pad), s, spaces(onRight+pad)) 416 | default: 417 | return fmt.Sprintf("%s%s%s", spaces(onLeft+pad), sty.ApplyTo(s), spaces(onRight+pad)) 418 | } 419 | } 420 | 421 | // justLeft is left-justified text with padding and style 422 | func justLeft(s string, width int, pad int, sty *Style) string { 423 | contentLen := len(s) 424 | onRight := width - contentLen 425 | if onRight < 0 { 426 | onRight = 0 427 | } 428 | switch { 429 | case sty == nil: 430 | return fmt.Sprintf("%s%s%s", spaces(pad), s, spaces(onRight+pad)) 431 | default: 432 | return fmt.Sprintf("%s%s%s", spaces(pad), sty.ApplyTo(s), spaces(onRight+pad)) 433 | } 434 | } 435 | 436 | // justRight is right-justified text with padding and style 437 | func justRight(s string, width int, pad int, sty *Style) string { 438 | contentLen := len(s) 439 | onLeft := width - contentLen 440 | if onLeft < 0 { 441 | onLeft = 0 442 | } 443 | switch { 444 | case sty == nil: 445 | return fmt.Sprintf("%s%s%s", spaces(onLeft+pad), s, spaces(pad)) 446 | default: 447 | return fmt.Sprintf("%s%s%s", spaces(onLeft+pad), sty.ApplyTo(s), spaces(pad)) 448 | } 449 | 450 | } 451 | 452 | // wrap will break long lines on breakpoints space, :, ., /, \, -. If 453 | // line is too long without breakpoints, will do dumb wrap at width w. 454 | func wrap(s string, w int) []string { 455 | var out []string 456 | var wrapped string 457 | rem := s 458 | for len(rem) > 0 { 459 | wrapped, rem = wrapSubString(rem, w, " :.-/\\") 460 | out = append(out, wrapped) 461 | } 462 | return out 463 | } 464 | 465 | // wrapSubString - don't call directly. Works with wrap to recursively 466 | // split a string at the specified breakpoints. 467 | func wrapSubString(s string, w int, breakpts string) (wrapped string, remainder string) { 468 | 469 | if len(s) <= w { 470 | return strings.TrimSpace(s), "" 471 | } 472 | 473 | ind := strings.LastIndexAny(s[0:w], breakpts) 474 | switch { 475 | case ind > 0: 476 | return strings.TrimSpace(s[0 : ind+1]), strings.TrimSpace(s[ind+1 : len(s)]) 477 | case ind == -1: 478 | return strings.TrimSpace(s[0:w]), strings.TrimSpace(s[w:len(s)]) 479 | } 480 | return "", "" 481 | } 482 | 483 | // spaces is a convenience function to get n spaces repeated 484 | func spaces(n int) string { 485 | return strings.Repeat(" ", n) 486 | } 487 | 488 | // width returns the full table computed width including padding 489 | func (t *Table) width() int { 490 | return sum(extractComputedWidth(t)) + len(t.columns)*2*t.pad 491 | } 492 | 493 | // automagically determine column widths. See if it can fit inside 494 | // max width. If not, make intelligent guess about which should be 495 | // made multi-line 496 | func (t *Table) computeColWidths() error { 497 | computeNaturalWidths(t) 498 | switch { 499 | case simpleStrategy(t): 500 | return nil 501 | case wrapWidestStrategy(t): 502 | return nil 503 | case overflowStrategy(t): 504 | return nil 505 | } 506 | return fmt.Errorf("no table rendering strategy suitable") 507 | } 508 | 509 | // simpleStrategy sets all column widths to their natural width. 510 | // Successful if the whole table fits inside maxWidth (including pad) 511 | func simpleStrategy(t *Table) bool { 512 | natWidths := extractNatWidth(t) 513 | colWPadded := mapAdd(natWidths, 2*t.pad) 514 | totalWidth := sum(colWPadded) 515 | 516 | if totalWidth <= t.maxWidth { 517 | for i := range t.columns { 518 | t.columns[i].computedWidth = natWidths[i] 519 | } 520 | return true 521 | } 522 | return false 523 | } 524 | 525 | // wrapWidestStrategy wraps the column with the largest natural width. 526 | // Successful if the wrapped width >50% of natural width 527 | func wrapWidestStrategy(t *Table) bool { 528 | naturalWidths := extractNatWidth(t) 529 | maxI, maxW := max(naturalWidths) 530 | tableMaxW := t.maxWidth - 2*len(t.columns)*t.pad 531 | wrapW := tableMaxW - sumWithoutIndex(naturalWidths, maxI) 532 | if wrappedWidthOk(wrapW, maxW) { 533 | for i := range t.columns { 534 | switch i { 535 | case maxI: 536 | t.columns[i].computedWidth = wrapW 537 | t.columns[i].wrap = true 538 | default: 539 | t.columns[i].computedWidth = t.columns[i].naturalWidth 540 | } 541 | } 542 | return true 543 | } 544 | return false 545 | } 546 | 547 | // overflowStrategy is the fallback if no other strategy makes the 548 | // table fit within the natural width. Sets all columns to their 549 | // natural width and lets the terminal wrap the lines. 550 | func overflowStrategy(t *Table) bool { 551 | for i, col := range t.columns { 552 | t.columns[i].computedWidth = col.naturalWidth 553 | } 554 | return true 555 | } 556 | 557 | // convenience function for extracting natural width as []int 558 | // from []Col 559 | func extractNatWidth(t *Table) []int { 560 | out := make([]int, len(t.columns)) 561 | for i, col := range t.columns { 562 | out[i] = col.naturalWidth 563 | } 564 | return out 565 | } 566 | 567 | // convenience function for extracting computed width as []int 568 | // from []Col 569 | func extractComputedWidth(t *Table) []int { 570 | out := make([]int, len(t.columns)) 571 | for i, col := range t.columns { 572 | out[i] = col.computedWidth 573 | } 574 | return out 575 | } 576 | 577 | // computes natural column widths and stores in table.columns.naturalWidth 578 | func computeNaturalWidths(t *Table) { 579 | maxColW := make([]int, len(t.columns)) 580 | 581 | for _, row := range t.rows { 582 | for col, cell := range row.cells { 583 | if cell.width > maxColW[col] { 584 | maxColW[col] = cell.width 585 | } 586 | } 587 | } 588 | 589 | for col, header := range t.headers { 590 | if header.width > maxColW[col] { 591 | maxColW[col] = header.width 592 | } 593 | } 594 | 595 | for i, natWidth := range maxColW { 596 | t.columns[i].naturalWidth = natWidth 597 | } 598 | } 599 | 600 | func sum(n []int) int { 601 | total := 0 602 | for _, num := range n { 603 | total += num 604 | } 605 | return total 606 | } 607 | 608 | // sum of all values except value at index 609 | func sumWithoutIndex(n []int, index int) int { 610 | total := 0 611 | for idx, num := range n { 612 | switch idx { 613 | case index: 614 | continue 615 | default: 616 | total += num 617 | } 618 | } 619 | return total 620 | } 621 | 622 | // wrappedWidthOk true if wrapped width >50% natural width 623 | func wrappedWidthOk(wrapW int, naturalW int) bool { 624 | if float64(wrapW)/float64(naturalW) >= 0.5 { 625 | return true 626 | } 627 | return false 628 | } 629 | 630 | // positive numbers only, returns index of first logical max 631 | func max(n []int) (index int, biggest int) { 632 | for i, num := range n { 633 | if num > biggest { 634 | biggest = num 635 | index = i 636 | } 637 | } 638 | return 639 | } 640 | 641 | // add inc to every number in n, return new array 642 | func mapAdd(n []int, inc int) []int { 643 | ret := make([]int, len(n)) 644 | for i, num := range n { 645 | ret[i] = num + inc 646 | } 647 | return ret 648 | } 649 | --------------------------------------------------------------------------------