ASCII progress bar in Chez Scheme

2020 May 06 @travishinkelman.com

As an impatient person, I typically use progress bars for any code that takes more than a few minutes to run. In a previous post, I wrote about creating ASCII progress bars in R and Racket. The Racket version depended on the raart module, which "provides an algebraic model of ASCII that can be used for art, user interfaces, and diagrams." Because I'm not aware of any such library for Chez Scheme [1], I was left feeling stuck.

Eventually, I found the correct combination of search terms and learned that the solution is simple [2]. The carriage return (\r) resets the cursor to the beginning of the line of output and allows for overwriting the previous content, which creates the animated effect of a progress bar advancing [3].

Armed with this new knowledge, I modified my old Racket code to make a progress bar that works in Chez Scheme and Racket. In the old Racket code, I wrote inflexible procedures that required the user to provide percent progress as an integer. We will relax that requirement and provide an option to change the width of the progress bar.

The first step is to write a procedure that will generate the text for a single iteration of the progress bar.

;; tick doesn't mean iteration here
;; it means number of symbols displayed on progress bar
(define (generate-bar tick percent total-width)
  (let ([num-equals (if (= tick 0) 0 (- tick 1))]
        [arrow (if (= tick 0) "" ">")]
        [width (- total-width 6)]
        [places-string  
         (cond
          [(< percent 10) "  "]
          [(< percent 100) " "]
          [else ""])])
    (string-append
     "["
     (make-string num-equals #\=)
     arrow
     (make-string (- width tick) #\-)
     "]"
     places-string
     (number->string percent)
     "%")))

generate-bar simply draws the inputs without doing any calculations. total-width is measured in the number of characters. A tick is a single character to indicate completion [4]. The places-string keeps the percent completed number at 3 characters so that the total length of the bar doesn't change and distract from the intended animation effect.

> (display (generate-bar 8 20 40))
  [=======>--------------------------------] 20%

Now, we need a procedure that will draw a new progress bar on every iteration. I'm using case-lambda to make the total-width argument optional [5]. case-lambda matches on the number of arguments to select which branch to follow. In this case, I use recursion for the branch where total-width is not specified. At other times, I've used a helper procedure that contains all the core logic and uses all of the arguments. In that case, case-lambda is a wrapper for the helper procedure and is not recursive.

The progress procedure does the math to generate the progress bar and uses the carriage return \r to overwrite the progress bar on each iteration. We subtract 6 from the total-width to account for the characters that are not part of the bar, i.e., [, ], and 100%.

(define progress
  (case-lambda
    [(iter max-iter) (progress iter max-iter 80)]
    [(iter max-iter total-width)
     (let* ([prop (/ iter max-iter)]
            [percent (round (* prop 100))]
            [tick (round (* prop (- total-width 6)))])
       (display (string-append "\r" (generate-bar tick percent total-width))))]))

use-progress shows how progress can be used in a recursive procedure. The sleep procedure in Chez Scheme is not very user friendly. It requires creating a time record of type time-duration. make-time creates that record from two integer arguments. The first is the number of nanoseconds and the second is the number of seconds. Thus, (make-time 'time-duration (flonum->fixnum 1e8) 0) creates a time duration of 0.1 seconds. In Racket, you can write (sleep 0.1). If sleep is new to you, it just slows down this loop so that you can see the progress bar advancing. You wouldn't use sleep in your actual long-running procedure.

(define (use-progress n)
  (let loop ([i 0])
    (cond [(> i n) (newline)]
          [else (progress i n)
                (sleep (make-time 'time-duration (flonum->fixnum 1e8) 0))
                (loop (add1 i))])))

As an alternative to a progress bar, you could just update a counter. In the example below, if you call (use-progress-simple 10), then the first number in the string, Rep 0 of 10, will be incremented in each iteration.

(define (use-progress-simple n)
  (let ([n-string (number->string n)])
    (let loop ([i 0])
      (cond [(> i n) (newline)]
            [else (display (string-append
                            "\rRep "
                            (number->string i)
                            " of "
                            n-string))
                  (sleep (make-time 'time-duration (flonum->fixnum 1e8) 0))
                  (loop (add1 i))]))))

I will leave you with an example of the counter approach in R.

use_progress_simple <- function(n){
  for (i in 1:n){
    cat("\rRep", i, "of", n)
    Sys.sleep(0.1)
  }
}

[1] I also made a GUI progress bar for Racket, but Chez Scheme doesn't have easy (any?) GUI capabilities so that was not an option here.

[2] I subsequently noticed that the carriage return "trick" was included in code for a C++ progress bar, but carriage return was not explicitly mentioned.

[3] After filling my carriage return knowledge gap, it is clear that the raart module was overkill for this simple progress bar.

[4] I've chosen = as the completion character following the style of R's progress package.

[5] I chose 80 as the default total-width because that is the default width of the macOS Terminal app. If the total-width is wider than your terminal width, then your bar will flow over to the next line and the overwriting will happen on the wrong line.