diff --git a/clifmt.go b/printer.go similarity index 95% rename from clifmt.go rename to printer.go index bf9a668..8a3cb8d 100644 --- a/clifmt.go +++ b/printer.go @@ -12,6 +12,8 @@ import ( "strings" ) +var ErrInvalidFormat = fmt.Errorf("invalid format") + var theme = color.DefaultTheme() var ( @@ -25,6 +27,9 @@ var ( func Sprintf(format string, a ...interface{}) (string, error) { // 1. Pre-process format with 'fmt' formatted := fmt.Sprintf(format, a...) + if strings.Contains(formatted, "%!") { // error + return "", ErrInvalidFormat + } // 2. Protect escaped characters with tokens formatted = strings.Replace(formatted, "\\$", dollarToken, -1) diff --git a/clifmt_test.go b/printer_test.go similarity index 100% rename from clifmt_test.go rename to printer_test.go diff --git a/progress.go b/progress.go new file mode 100644 index 0000000..4ca10b4 --- /dev/null +++ b/progress.go @@ -0,0 +1,107 @@ +package clifmt + +import ( + "fmt" + "reflect" + "strings" +) + +var ErrNoNewline = fmt.Errorf("no newline allowed in progress mode") + +// Printpf prints a progress (dynamic) line that rewrites itself +// on arguments' update +func Printpf(format string, args ...interface{}) error { + + // 1. check format + if strings.ContainsAny(format, "\n\r") { + return ErrNoNewline + } + + // 2. init + fixed := make([]interface{}, len(args), len(args)) // actual values + update := make([]chan interface{}, 0, len(args)) // channels that update values + updateIndex := make([]int, 0, len(args)) // association [order -> index in @fixed] + + // 3. manage fixed values vs. updatable values (channels) + for i, arg := range args { + + // channel -> keep Zero value + store channel + if reflect.TypeOf(arg).Kind() == reflect.Chan { + updateIndex = append(updateIndex, i) + update = append(update, arg.(chan interface{})) + continue + } + + // raw -> set value + fixed[i] = arg + } + + // 4. launch dynamic select for each channel + maxlen := 0 + nselect(update, func(i int, value interface{}, ok bool) { + + // channel is closed -> do nothing + if !ok { + return + } + + // extract real index + index := updateIndex[i] + + // update value + fixed[index] = value + + // ignore on errors (updatable values still NIL) + str, err := Sprintf(format, fixed...) + if err != nil { + return + } + reallen := displaySize(str) + + // print string + fmt.Printf("\r%s", str) + + // pad right to end of max size + if reallen < maxlen { + pad := make([]byte, 0, maxlen-reallen) + for i := reallen; i < maxlen; i++ { + pad = append(pad, ' ') + } + fmt.Printf("%s", pad) + } else { + maxlen = reallen + } + + }) + + fmt.Printf("\n") + + return nil + +} + +// nselect selects on N channels +func nselect(channels []chan interface{}, handler func(int, interface{}, bool)) { + + // 1. build the case list containing each channel + cases := make([]reflect.SelectCase, len(channels)) + for i, ch := range channels { + cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)} + } + + // 2. wait for selections + remaining := len(cases) + for remaining > 0 { + index, value, ok := reflect.Select(cases) + + // (1) Closed + if !ok { + cases[index].Chan = reflect.ValueOf(nil) + remaining-- + continue + } + + // (2) Received data + handler(index, value, ok) + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..ca5bbc9 --- /dev/null +++ b/util.go @@ -0,0 +1,22 @@ +package clifmt + +import ( + "regexp" +) + +var esc = regexp.MustCompile(`(?m)\[(?:\d+;)*\d+m`) + +// displaySize returns the real size escaping special characters +func displaySize(s string) int { + + // 1. get actual size + size := len(s) + + // 2. get all terminal coloring matches + matches := esc.FindAllString(s, -1) + for _, m := range matches { + size -= len(m) + } + + return size +}