diff --git a/carver.go b/carver.go index 9b7ea93..b694a8f 100644 --- a/carver.go +++ b/carver.go @@ -15,12 +15,13 @@ import ( "time" pigo "github.com/esimov/pigo/core" + "github.com/pkg/errors" ) var usedSeams []UsedSeams -// TempImage temporary image file. -var TempImage = fmt.Sprintf("%d.jpg", time.Now().Unix()) +// tmpFile temporary image file. +var tmpFile = fmt.Sprintf("%d.jpg", time.Now().Unix()) // Carver is the main entry struct having as parameters the newly generated image width, height and seam points. type Carver struct { @@ -74,7 +75,7 @@ func (c *Carver) set(x, y int, px float64) { // // - the minimum energy level is calculated by summing up the current pixel value // with the minimum pixel value of the neighboring pixels from the previous row. -func (c *Carver) ComputeSeams(img *image.NRGBA, p *Processor) { +func (c *Carver) ComputeSeams(img *image.NRGBA, p *Processor) error { var srcImg *image.NRGBA newImg := image.NewNRGBA(image.Rect(0, 0, img.Bounds().Dx(), img.Bounds().Dy())) draw.Draw(newImg, newImg.Bounds(), img, image.ZP, draw.Src) @@ -88,27 +89,25 @@ func (c *Carver) ComputeSeams(img *image.NRGBA, p *Processor) { sobel := SobelFilter(Grayscale(newImg), float64(p.SobelThreshold)) if p.FaceDetect { - if len(p.Classifier) == 0 { - log.Fatal("Please provide a face classifier!") - } + defer removeTempFile(tmpFile) cascadeFile, err := ioutil.ReadFile(p.Classifier) if err != nil { - log.Fatalf("Error reading the cascade file: %v", err) + return errors.New(fmt.Sprintf("error reading the cascade file: %v", err)) } - tmpImg, err := os.OpenFile(TempImage, os.O_CREATE|os.O_WRONLY, 0755) + tmpImg, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY, 0755) if err != nil { - log.Fatalf("Cannot access temporary image file: %v", err) + return errors.New(fmt.Sprintf("cannot access the temporary image file: %v", err)) } if err := jpeg.Encode(tmpImg, img, &jpeg.Options{Quality: 100}); err != nil { - log.Fatalf("Cannot encode temporary image file: %v", err) + return errors.New(fmt.Sprintf("cannot encode the temporary image file: %v", err)) } - src, err := pigo.GetImage(TempImage) + src, err := pigo.GetImage(tmpFile) if err != nil { - log.Fatalf("Cannot open the image file: %v", err) + return errors.New(fmt.Sprintf("cannot open the temporary image file: %v", err)) } pixels := pigo.RgbToGrayscale(src) @@ -162,7 +161,7 @@ func (c *Carver) ComputeSeams(img *image.NRGBA, p *Processor) { signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { for range c { - RemoveTempImage(TempImage) + removeTempFile(tmpFile) os.Exit(1) } }() @@ -200,6 +199,7 @@ func (c *Carver) ComputeSeams(img *image.NRGBA, p *Processor) { right := c.get(0, y) + math.Min(c.get(c.Width-1, y-1), c.get(c.Width-2, y-1)) c.set(c.Width-1, y, right) } + return nil } // FindLowestEnergySeams find the lowest vertical energy seam. @@ -382,10 +382,10 @@ func (c *Carver) RotateImage270(src *image.NRGBA) *image.NRGBA { return dst } -// RemoveTempImage removes the temporary image generated during face detection process. -func RemoveTempImage(tmpImage string) { +// removeTempFile removes the temporary image generated during face detection process. +func removeTempFile(tmpFile string) { // Remove temporary image file. - if _, err := os.Stat(tmpImage); err == nil { - os.Remove(tmpImage) + if _, err := os.Stat(tmpFile); err == nil { + os.Remove(tmpFile) } } diff --git a/cmd/caire/main.go b/cmd/caire/main.go index 231841f..ae77e20 100644 --- a/cmd/caire/main.go +++ b/cmd/caire/main.go @@ -1,18 +1,21 @@ package main import ( + "errors" "flag" "fmt" "io" - "io/ioutil" "log" "os" - "path" + "os/signal" "path/filepath" - "strings" + "runtime" + "sync" + "syscall" "time" "github.com/esimov/caire" + "github.com/esimov/caire/utils" "golang.org/x/term" ) @@ -26,19 +29,32 @@ Content aware image resize library. ` -// PipeName is the file name that indicates stdin/stdout is being used. -const PipeName = "-" +// pipeName is the file name that indicates stdin/stdout is being used. +const pipeName = "-" + +// maxWorkers sets the maximum number of concurrently running workers. +const maxWorkers = 20 + +// result holds the relevant information about the triangulation process and the generated image. +type result struct { + path string + err error +} + +var ( + // imgurl holds the file being accessed be it normal file or pipe name. + imgurl *os.File + // spinner used to instantiate and call the progress indicator. + spinner *utils.Spinner +) // Version indicates the current build version. var Version string -// Supported image files. -var extensions = []string{".jpg", ".png", ".jpeg", ".bmp", ".gif"} - var ( // Flags - source = flag.String("in", PipeName, "Source") - destination = flag.String("out", PipeName, "Destination") + source = flag.String("in", pipeName, "Source") + destination = flag.String("out", pipeName, "Destination") blurRadius = flag.Int("blur", 1, "Blur radius") sobelThreshold = flag.Int("sobel", 10, "Sobel filter threshold") newWidth = flag.Int("width", 0, "New width") @@ -50,6 +66,11 @@ var ( faceDetect = flag.Bool("face", false, "Use face detection") faceAngle = flag.Float64("angle", 0.0, "Plane rotated faces angle") cascade = flag.String("cc", "", "Cascade classifier") + workers = flag.Int("conc", runtime.NumCPU(), "Number of files to process concurrently") + + // File related variables + fs os.FileInfo + err error ) func main() { @@ -61,141 +82,314 @@ func main() { } flag.Parse() - if *newWidth > 0 || *newHeight > 0 || *percentage || *square { - p := &caire.Processor{ - BlurRadius: *blurRadius, - SobelThreshold: *sobelThreshold, - NewWidth: *newWidth, - NewHeight: *newHeight, - Percentage: *percentage, - Square: *square, - Debug: *debug, - Scale: *scale, - FaceDetect: *faceDetect, - FaceAngle: *faceAngle, - Classifier: *cascade, - } - process(p, *destination, *source) - } else { - flag.Usage() - log.Fatal("\n\x1b[31mPlease provide a width, height or percentage for image rescaling!\x1b[39m") + proc := &caire.Processor{ + BlurRadius: *blurRadius, + SobelThreshold: *sobelThreshold, + NewWidth: *newWidth, + NewHeight: *newHeight, + Percentage: *percentage, + Square: *square, + Debug: *debug, + Scale: *scale, + FaceDetect: *faceDetect, + FaceAngle: *faceAngle, + Classifier: *cascade, } - caire.RemoveTempImage(caire.TempImage) -} + spinnerText := fmt.Sprintf("%s %s", + utils.DecorateText("⚡ CAIRE", utils.StatusMessage), + utils.DecorateText("is resizing the image...", utils.DefaultMessage)) + spinner = utils.NewSpinner(spinnerText, time.Millisecond*200, true) -func process(p *caire.Processor, dstname, srcname string) { - var src io.Reader - if srcname == PipeName { - if term.IsTerminal(int(os.Stdin.Fd())) { - log.Fatalln("`-` should be used with a pipe for stdin") - } - src = os.Stdin - } else { - srcinfo, err := os.Stat(srcname) - if err != nil { - log.Fatalf("Unable to open source: %v", err) + if *newWidth > 0 || *newHeight > 0 || *percentage || *square { + if len(*cascade) == 0 { + log.Fatalf(utils.DecorateText("Please specify a face classifier in case you are using the -face flag!\n", utils.ErrorMessage)) } - if srcinfo.IsDir() { - dstinfo, err := os.Stat(dstname) - if err != nil { - log.Fatalf("Unable to get dir stats: %v", err) - } - if !dstinfo.IsDir() { - log.Fatalf("Please specify a directory as destination!") - } + // Supported files + validExtensions := []string{".jpg", ".png", ".jpeg", ".bmp", ".gif"} - files, err := ioutil.ReadDir(srcname) - if err != nil { - log.Fatalf("Unable to read dir: %v", err) - } + // Check if source path is a local image or URL. + if utils.IsValidUrl(*source) { + src, err := utils.DownloadImage(*source) + defer src.Close() + defer os.Remove(src.Name()) - // Range over all the image files and save them into a slice. - var images []string - for _, f := range files { - ext := filepath.Ext(f.Name()) - for _, iex := range extensions { - if ext == iex { - images = append(images, f.Name()) - } + fs, err = src.Stat() + if err != nil { + log.Fatalf( + utils.DecorateText("Failed to load the source image: %v", utils.ErrorMessage), + utils.DecorateText(err.Error(), utils.DefaultMessage), + ) + } + img, err := os.Open(src.Name()) + if err != nil { + log.Fatalf( + utils.DecorateText("Unable to open the temporary image file: %v", utils.ErrorMessage), + utils.DecorateText(err.Error(), utils.DefaultMessage), + ) + } + imgurl = img + } else { + // Check if the source is a pipe name or a regular file. + if *source == pipeName { + fs, err = os.Stdin.Stat() + } else { + fs, err = os.Stat(*source) + } + if err != nil { + log.Fatalf( + utils.DecorateText("Failed to load the source image: %v", utils.ErrorMessage), + utils.DecorateText(err.Error(), utils.DefaultMessage), + ) + } + } + + now := time.Now() + + switch mode := fs.Mode(); { + case mode.IsDir(): + var wg sync.WaitGroup + // Read destination file or directory. + _, err := os.Stat(*destination) + if err != nil { + err = os.Mkdir(*destination, 0755) + if err != nil { + log.Fatalf( + utils.DecorateText("Unable to get dir stats: %v\n", utils.ErrorMessage), + utils.DecorateText(err.Error(), utils.DefaultMessage), + ) } } - // Process images from directory. - for _, img := range images { - // Get the file base name. - name := strings.TrimSuffix(img, filepath.Ext(img)) - - process(p, filepath.Join(dstname, name+".jpg"), filepath.Join(srcname, img)) + // Limit the concurrently running workers to maxWorkers. + if *workers <= 0 || *workers > maxWorkers { + *workers = runtime.NumCPU() } - return - } - f, err := os.Open(srcname) - if err != nil { - log.Fatalf("Unable to open source file: %v", err) + // Process recursively the image files from the specified directory concurrently. + ch := make(chan result) + done := make(chan interface{}) + defer close(done) + + paths, errc := walkDir(done, *source, validExtensions) + + wg.Add(*workers) + for i := 0; i < *workers; i++ { + go func() { + defer wg.Done() + consumer(done, paths, *destination, proc, ch) + }() + } + + // Close the channel after the values are consumed. + go func() { + defer close(ch) + wg.Wait() + }() + + // Consume the channel values. + for res := range ch { + printStatus(res.path, res.err) + } + + if err := <-errc; err != nil { + fmt.Fprintf(os.Stderr, utils.DecorateText(err.Error(), utils.ErrorMessage)) + } + + case mode.IsRegular() || mode&os.ModeNamedPipe != 0: // check for regular files or pipe names + ext := filepath.Ext(*destination) + if !isValidExtension(ext, validExtensions) && *destination != pipeName { + log.Fatalf(utils.DecorateText(fmt.Sprintf("%v file type not supported", ext), utils.ErrorMessage)) + } + + err := processor(*source, *destination, proc) + printStatus(*destination, err) + } + fmt.Fprintf(os.Stderr, "\nExecution time: %s\n", utils.DecorateText(fmt.Sprintf("%s", utils.FormatTime(time.Since(now))), utils.SuccessMessage)) + } else { + flag.Usage() + log.Fatal(fmt.Sprintf("%s%s", + utils.DecorateText("\nPlease provide a width, height or percentage for image rescaling!", utils.ErrorMessage), + utils.DefaultColor, + )) + } +} + +// walkDir starts a goroutine to walk the specified directory tree in recursive manner +// and send the path of each regular file on the string channel. +// It sends the result of the walk on the error channel. +// It terminates in case done channel is closed. +func walkDir( + done <-chan interface{}, + src string, + srcExts []string, +) (<-chan string, <-chan error) { + pathChan := make(chan string) + errChan := make(chan error, 1) + + go func() { + // Close the paths channel after Walk returns. + defer close(pathChan) + + errChan <- filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + isFileSupported := false + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + + // Get the file base name. + fx := filepath.Ext(info.Name()) + for _, ext := range srcExts { + if ext == fx { + isFileSupported = true + break + } + } + + if isFileSupported { + select { + case <-done: + return errors.New("directory walk cancelled") + case pathChan <- path: + } + } + return nil + }) + }() + return pathChan, errChan +} + +// consumer reads the path names from the paths channel and +// calls the triangulator processor against the source image +// then sends the results on a new channel. +func consumer( + done <-chan interface{}, + paths <-chan string, + dest string, + proc *caire.Processor, + res chan<- result, +) { + for src := range paths { + dest := filepath.Join(dest, filepath.Base(src)) + err := processor(src, dest, proc) + + select { + case <-done: + return + case res <- result{ + path: src, + err: err, + }: + } + } +} + +// processor calls the resizer method over the source image and +// returns the error in case exists, otherwise nil. +func processor(in, out string, proc *caire.Processor) error { + var err error + + src, dst, err := pathToFile(in, out) + defer src.(*os.File).Close() + defer dst.(*os.File).Close() + + // Capture CTRL-C signal and restore the cursor visibility back. + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-signalChan + func() { + spinner.RestoreCursor() + os.Exit(1) + }() + }() + + // Start the progress indicator. + spinner.Start() + err = proc.Process(src, dst) + + stopMsg := fmt.Sprintf("%s %s", + utils.DecorateText("⚡ CAIRE", utils.StatusMessage), + utils.DecorateText("is resizing the image... ✔", utils.DefaultMessage)) + spinner.StopMsg = stopMsg + + // Stop the progress indicator. + spinner.Stop() + + return err +} + +// pathToFile converts the source and destination paths to readable and writable files. +func pathToFile(in, out string) (io.Reader, io.Writer, error) { + var ( + src io.Reader + dst io.Writer + err error + ) + // Check if the source path is a local image or URL. + if utils.IsValidUrl(in) { + src = imgurl + } else { + // Check if the source is a pipe name or a regular file. + if in == pipeName { + if term.IsTerminal(int(os.Stdin.Fd())) { + return nil, nil, errors.New("`-` should be used with a pipe for stdin") + } + src = os.Stdin + } else { + src, err = os.Open(in) + if err != nil { + return nil, nil, errors.New( + fmt.Sprintf("unable to open the source file: %v", err), + ) + } } - defer f.Close() - src = f } - var dst io.Writer - if dstname == PipeName { + // Check if the destination is a pipe name or a regular file. + if out == pipeName { if term.IsTerminal(int(os.Stdout.Fd())) { - log.Fatalln("`-` should be used with a pipe for stdout") + return nil, nil, errors.New("`-` should be used with a pipe for stdout") } dst = os.Stdout } else { - f, err := os.OpenFile(dstname, os.O_CREATE|os.O_WRONLY, 0755) + dst, err = os.OpenFile(out, os.O_CREATE|os.O_WRONLY, 0755) if err != nil { - log.Fatalf("Unable to open output file: %v", err) + return nil, nil, errors.New( + fmt.Sprintf("unable to create the destination file: %v", err), + ) } - defer f.Close() - dst = f } + return src, dst, nil +} - s := new(spinner) - s.start("Processing...") - - start := time.Now() - err := p.Process(src, dst) - s.stop() - - if err == nil { - log.Printf("\nRescaled in: \x1b[92m%.2fs\n\x1b[0m", time.Since(start).Seconds()) - if dstname != PipeName { - log.Printf("\x1b[39mSaved as: \x1b[92m%s \n\n\x1b[0m", path.Base(dstname)) - } +// printStatus displays the relavant information about the triangulation process. +func printStatus(fname string, err error) { + if err != nil { + fmt.Fprintf(os.Stderr, + utils.DecorateText("\nError resizing the image: %s", utils.ErrorMessage), + utils.DecorateText(fmt.Sprintf("\n\tReason: %v\n", err.Error()), utils.DefaultMessage), + ) } else { - log.Printf("\nError rescaling image %s. Reason: %s\n", srcname, err.Error()) + if fname != pipeName { + fmt.Fprintf(os.Stderr, fmt.Sprintf("\nThe resized image has been saved as: %s %s\n", + utils.DecorateText(filepath.Base(fname), utils.SuccessMessage), + utils.DefaultColor, + )) + } } } -type spinner struct { - stopChan chan struct{} -} - -// Start process -func (s *spinner) start(message string) { - s.stopChan = make(chan struct{}, 1) - - go func() { - for { - for _, r := range `-\|/` { - select { - case <-s.stopChan: - return - default: - fmt.Fprintf(os.Stderr, "\r%s%s %c%s", message, "\x1b[92m", r, "\x1b[39m") - time.Sleep(time.Millisecond * 100) - } - } +// isValidExtension checks for the supported extensions. +func isValidExtension(ext string, extensions []string) bool { + for _, ex := range extensions { + if ex == ext { + return true } - }() -} - -// End process -func (s *spinner) stop() { - s.stopChan <- struct{}{} + } + return false } diff --git a/process.go b/process.go index 24573bd..55a6420 100644 --- a/process.go +++ b/process.go @@ -9,9 +9,9 @@ import ( "image/jpeg" "image/png" "io" + "math" "os" "path/filepath" - "math" "github.com/nfnt/resize" "github.com/pkg/errors" @@ -64,6 +64,7 @@ func (p *Processor) Resize(img *image.NRGBA) (image.Image, error) { newWidth int newHeight int pw, ph int + err error ) xCount, yCount = 0, 0 @@ -85,23 +86,30 @@ func (p *Processor) Resize(img *image.NRGBA) (image.Image, error) { if p.NewHeight == 0 { newHeight = p.NewHeight } - reduce := func() { + reduce := func() error { width, height := img.Bounds().Max.X, img.Bounds().Max.Y c = NewCarver(width, height) - c.ComputeSeams(img, p) + if err := c.ComputeSeams(img, p); err != nil { + return err + } seams := c.FindLowestEnergySeams() img = c.RemoveSeam(img, seams, p.Debug) if isGif { g = encodeImageToGif(img) } + return nil } - enlarge := func() { + enlarge := func() error { width, height := img.Bounds().Max.X, img.Bounds().Max.Y c = NewCarver(width, height) - c.ComputeSeams(img, p) + if err := c.ComputeSeams(img, p); err != nil { + return err + } seams := c.FindLowestEnergySeams() img = c.AddSeam(img, seams, p.Debug) + + return nil } if p.Percentage || p.Square { @@ -160,7 +168,7 @@ func (p *Processor) Resize(img *image.NRGBA) (image.Image, error) { // Use this option to rescale the image proportionally prior resizing. // First the image is scaled down preserving the image aspect ratio, // then the seam carving algorithm is applied only to the remaining pixels. - + // Prevent memory overflow issue in case of huge images by switching to scaling first option if img.Bounds().Dx() > maxResizeWithoutScaling || img.Bounds().Dy() > maxResizeWithoutScaling { @@ -176,12 +184,12 @@ func (p *Processor) Resize(img *image.NRGBA) (image.Image, error) { // Scale both the w and h by the smaller factor (i.e Min(wScaleFactor, hScaleFactor)) // This will scale one side directly to the target length, // and the other proportionally larger than the target length. - // Example: input: 5000x2500, scale: 2160x1080, final target: 1920x1080 - wScaleFactor := float64(c.Width) / float64(p.NewWidth) + // Example: input: 5000x2500, scale: 2160x1080, final target: 1920x1080 + wScaleFactor := float64(c.Width) / float64(p.NewWidth) hScaleFactor := float64(c.Height) / float64(p.NewHeight) - scaleWidth := math.Round(float64(c.Width) / math.Min(wScaleFactor, hScaleFactor)) //post scale width + scaleWidth := math.Round(float64(c.Width) / math.Min(wScaleFactor, hScaleFactor)) //post scale width scaleHeight := math.Round(float64(c.Height) / math.Min(wScaleFactor, hScaleFactor)) // post scale height - + newImg = resize.Resize(uint(scaleWidth), uint(scaleHeight), img, resize.Lanczos3) // The amount needed to remove by carving. One or both of these will be 0. newWidth = int(scaleWidth) - p.NewWidth @@ -222,7 +230,7 @@ func (p *Processor) Resize(img *image.NRGBA) (image.Image, error) { img = c.RotateImage270(img) } } - return img, nil + return img, err } // Process is the main function having as parameters an input reader and an output writer. @@ -355,4 +363,4 @@ func writeGifToFile(path string) error { } defer f.Close() return gif.EncodeAll(f, g) -} \ No newline at end of file +} diff --git a/utils/download.go b/utils/download.go new file mode 100644 index 0000000..bdd6a4f --- /dev/null +++ b/utils/download.go @@ -0,0 +1,54 @@ +package utils + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" +) + +// DownloadImage downloads the image from the internet and saves it into a temporary file. +func DownloadImage(url string) (*os.File, error) { + // Retrieve the url and decode the response body. + res, err := http.Get(url) + if err != nil { + return nil, errors.New(fmt.Sprintf("unable to download image file from URI: %s, status %v", url, res.Status)) + } + defer res.Body.Close() + + data, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, errors.New(fmt.Sprintf("unable to read response body: %s", err)) + } + + tmpfile, err := ioutil.TempFile("/tmp", "image") + if err != nil { + return nil, errors.New(fmt.Sprintf("unable to create temporary file: %v", err)) + } + + // Copy the image binary data into the temporary file. + _, err = io.Copy(tmpfile, bytes.NewBuffer(data)) + if err != nil { + return nil, errors.New(fmt.Sprintf("unable to copy the source URI into the destination file")) + } + return tmpfile, nil +} + +// IsValidUrl tests a string to determine if it is a well-structured url or not. +func IsValidUrl(uri string) bool { + _, err := url.ParseRequestURI(uri) + if err != nil { + return false + } + + u, err := url.Parse(uri) + if err != nil || u.Scheme == "" || u.Host == "" { + return false + } + + return true +} diff --git a/utils/spinner.go b/utils/spinner.go new file mode 100644 index 0000000..08dccc8 --- /dev/null +++ b/utils/spinner.go @@ -0,0 +1,101 @@ +package utils + +import ( + "fmt" + "io" + "os" + "runtime" + "strings" + "sync" + "time" + "unicode/utf8" +) + +// Spinner initializes the progress indicator. +type Spinner struct { + mu *sync.RWMutex + delay time.Duration + writer io.Writer + message string + lastOutput string + StopMsg string + hideCursor bool + stopChan chan struct{} +} + +// NewSpinner instantiates a new progress indicator. +func NewSpinner(msg string, d time.Duration, hideCursor bool) *Spinner { + return &Spinner{ + mu: &sync.RWMutex{}, + delay: d, + writer: os.Stderr, + message: msg, + hideCursor: hideCursor, + stopChan: make(chan struct{}, 1), + } +} + +// Start starts the progress indicator. +func (s *Spinner) Start() { + if s.hideCursor && runtime.GOOS != "windows" { + // hides the cursor + fmt.Fprintf(s.writer, "\033[?25l") + } + + go func() { + for { + for _, r := range `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` { + select { + case <-s.stopChan: + return + default: + s.mu.Lock() + + output := fmt.Sprintf("\r%s%s %c%s", s.message, SuccessColor, r, DefaultColor) + fmt.Fprintf(s.writer, output) + s.lastOutput = output + + s.mu.Unlock() + time.Sleep(s.delay) + } + } + } + }() +} + +// Stop stops the progress indicator. +func (s *Spinner) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + s.clear() + s.RestoreCursor() + if len(s.StopMsg) > 0 { + fmt.Fprintf(s.writer, s.StopMsg) + } + s.stopChan <- struct{}{} +} + +// RestoreCursor restores back the cursor visibility. +func (s *Spinner) RestoreCursor() { + if s.hideCursor && runtime.GOOS != "windows" { + // makes the cursor visible + fmt.Fprint(s.writer, "\033[?25h") + } +} + +// clear deletes the last line. Caller must hold the the locker. +func (s *Spinner) clear() { + n := utf8.RuneCountInString(s.lastOutput) + if runtime.GOOS == "windows" { + clearString := "\r" + strings.Repeat(" ", n) + "\r" + fmt.Fprint(s.writer, clearString) + s.lastOutput = "" + return + } + for _, c := range []string{"\b", "\127", "\b", "\033[K"} { // "\033[K" for macOS Terminal + fmt.Fprint(s.writer, strings.Repeat(c, n)) + } + fmt.Fprintf(s.writer, "\r\033[K") // clear line + s.lastOutput = "" +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..301ec4b --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,66 @@ +package utils + +import ( + "fmt" + "math" + "time" +) + +// MessageType is a placeholder for the various the message types. +type MessageType int + +// The message types used accross the CLI application. +const ( + DefaultMessage MessageType = iota + SuccessMessage + ErrorMessage + StatusMessage +) + +// Colors used accross the CLI application. +const ( + ErrorColor = "\x1b[31m" + SuccessColor = "\x1b[32m" + DefaultColor = "\x1b[0m" + StatusColor = "\x1b[36m" +) + +// DecorateText shows the message types in different colors. +func DecorateText(s string, msgType MessageType) string { + switch msgType { + case SuccessMessage: + s = SuccessColor + s + case ErrorMessage: + s = ErrorColor + s + case DefaultMessage: + s = DefaultColor + s + case StatusMessage: + s = StatusColor + s + default: + return s + } + return s + "\x1b[0m" +} + +// FormatTime formats time.Duration output to a human readable value. +func FormatTime(d time.Duration) string { + if d.Seconds() < 60.0 { + return fmt.Sprintf("%ds", int64(d.Seconds())) + } + if d.Minutes() < 60.0 { + remainingSeconds := math.Mod(d.Seconds(), 60) + return fmt.Sprintf("%dm:%ds", int64(d.Minutes()), int64(remainingSeconds)) + } + if d.Hours() < 24.0 { + remainingMinutes := math.Mod(d.Minutes(), 60) + remainingSeconds := math.Mod(d.Seconds(), 60) + return fmt.Sprintf("%dh:%dm:%ds", + int64(d.Hours()), int64(remainingMinutes), int64(remainingSeconds)) + } + remainingHours := math.Mod(d.Hours(), 24) + remainingMinutes := math.Mod(d.Minutes(), 60) + remainingSeconds := math.Mod(d.Seconds(), 60) + return fmt.Sprintf("%dd:%dh:%dm:%ds", + int64(d.Hours()/24), int64(remainingHours), + int64(remainingMinutes), int64(remainingSeconds)) +}