Skip to content

Progress Bars

Mordant provides a simple way to create animated progress bars in your terminal.

Basic Usage

You can use the progressBarLayout DSL to define the layout of your progress bar. Then you can start the animation either on a thread with animateOnThread, or using coroutines with [animateOnCoroutine].animateOnThread is JVM-only, but animateOnCoroutine is available on all platforms using the mordant-coroutines module.

Once the animation is started, you can update the progress bar by calling update and advance.

val progress = progressBarLayout {
    marquee(terminal.theme.warning("my-file-download.bin"), width = 15)
    percentage()
    progressBar()
    completed(style = terminal.theme.success)
    speed("B/s", style = terminal.theme.info)
    timeRemaining(style = magenta)
}.animateInCoroutine(terminal)

launch { progress.execute() }

// Update the progress as the download progresses
progress.update { total = 3_000_000_000 }
while (!progress.finished) {
    progress.advance(15_000_000)
    Thread.sleep(100)
}
val progress = progressBarLayout {
    marquee(terminal.theme.warning("my-file-download.bin"), width = 15)
    percentage()
    progressBar()
    completed(style = terminal.theme.success)
    speed("B/s", style = terminal.theme.info)
    timeRemaining(style = magenta)
}.animateOnThread(terminal)

val future = progress.execute()

// Update the progress as the download progresses
progress.update { total = 3_000_000_000 }
while (!progress.finished) {
    progress.advance(15_000_000)
    Thread.sleep(100)
}

// Optional: wait for the future to complete so that the final frame of the
// animation is rendered before the program exits.
future.get()

Changing Text While Animation is Running

You can pass data to the progress bar by using progressBarContextLayout, which allows you to set a context value that your progress bar can use to render dynamic text.

val progress = progressBarContextLayout<String> {
    text { "Status: $context" }
    progressBar()
    completed()
}.animateInCoroutine(terminal, context = "Starting", total = 4, completed = 1)

launch { progress.execute() }

val states = listOf("Downloading", "Extracting", "Done")
for (state in states) {
    delay(2.seconds)
    progress.update {
        context = state
        completed += 1
    }
}

Tip

If you want a builder instead of a DSL, you can use the ProgressLayoutBuilder

Multiple Progress Bars

You can create multiple progress bars running at the same time using MultiProgressBarAnimation. Call addTask for each progress bar you want, passing in the layout for that bar. You can use the same layout for multiple tasks, or different layouts for some of them.

You can call advance and update on each task to update them separately.

The columns of the progress bars will have their widths aligned to the same size by default, but you can change this by setting the alignColumns parameter in the layout.

val overallLayout = progressBarLayout(alignColumns = false) {
    progressBar(width = 20)
    percentage()
    timeElapsed(compact = false)
}
val taskLayout = progressBarContextLayout<Int> {
    text(fps = animationFps, align = TextAlign.LEFT) { "〉 step $context" }
}

val progress = MultiProgressBarAnimation(terminal).animateInCoroutine()
val overall = progress.addTask(overallLayout, total = 100)
val tasks = List(3) { progress.addTask(taskLayout, total = 1, completed = 1, context = 0) }

launch { progress.execute() }

for (i in 1..100) {
    overall.advance()
    tasks[i % 3].update { context = i }
    delay(100)
}

Tip

The progress animation will keep running until all tasks are finished. If you want to stop sooner, you can set all the tasks’ completed equal to their total, or cancel the coroutine scope or future that the animation is running in.

Available Progress Bar Cell Types

Mordant provides several cell types that you can use to build your progress bar layouts, or you can make your own with cell or text.

// Use a custom maker to build render the cells in a vertical definitionList
object VerticalProgressBarMaker : ProgressBarWidgetMaker {
    override fun build(rows: List<ProgressBarMakerRow<*>>): Widget {
        return definitionList {
            inline = true
            val widgets = MultiProgressBarWidgetMaker.buildCells(rows)
            for ((term, desc) in widgets.flatten().windowed(2, 2)) {
                entry(term, desc)
            }
        }
    }
}

val progress = progressBarLayout {
    text("text"); text("text")
    text("marquee"); marquee("marquee", width = 10, scrollWhenContentFits = true)
    text("completed"); completed()
    text("speed"); speed()
    text("percentage"); percentage()
    text("timeRemaining"); timeRemaining()
    text("timeElapsed"); timeElapsed()
    text("spinner"); spinner(Spinner.Lines())
    text("progressBar"); progressBar()
}.animateOnThread(terminal, maker = VerticalProgressBarMaker)

launch { progress.execute() }

while (!progress.finished) {
    progress.advance()
    delay(100)
}
Cell Type Description
text You can make a static text cell with text(""), or a dynamic one with text {""}
marquee A fixed-width text cell that scrolls its contents when they’re larger than the cell. You can make the content always scroll by setting scrollWhenContentFits=true
completed A cell that shows the completed count and optionally the total. It uses SI units for amounts larger than 1000
speed A cell that shows the speed of the progress, in bytes per second.
percentage A cell that shows the completed percentage.
timeRemaining A cell that shows the estimated time remaining, or optionally the elapsed time once a task finishes. If you want a different time format, you can do text { myFormat(calculateTimeRemaining()) }
timeElapsed A cell that shows the elapsed time. If you want a different time format, you can do text { myFormat(calculateTimeElapsed()) }
spinner A cell that shows an animated Spinner.
progressBar A cell that shows a progress bar.
cell A custom cell that can show any Widget

Animating on Custom Threads

If you want to run an animation on your own threading infrastructure instead of a Java Executor, there are a couple of ways to do it.

With runBlocking

If you are on JVM, you can still use animateOnThread, but call BlockingAnimator.runBlocking on you own thread instead of using execute.

For example, to run an animation with RxJava:

val progress = progressBarLayout { /* ... */ }.animateOnThread(terminal)
Completable.create { progress.runBlocking() }
    .subscribeOn(Schedulers.computation())
    .subscribe()

Calling refresh manually

If you aren’t on JVM or want even more control, you can create a MultiProgressBarAnimation and call refresh manually each time you want a new frame to be rendered.

val layout = progressBarLayout { /* ... */ }
val animation = MultiProgressBarAnimation(terminal)
val task = animation.addTask(layout, total = 100)

while (!animation.finished) {
    task.advance()
    animation.refresh()
    sleep(33)
}

// Refresh all cells to draw the final frame
animation.refresh(refreshAll = true)