From eec79066f3d6811899d668736537769e944bd1cb Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Thu, 20 Jun 2019 22:58:21 +0100 Subject: [PATCH] Smooth progress bar (#78) * [domain] rewrite and expand ansi codes available * [aws-api] refactor progress-bar * [domain] move progress-bar into Terminal * [aws-api] remove unused value * [domain] use unicode characters to get a smooth progress bar --- .../thorp/aws/api/UploadProgressLogging.scala | 22 +-- .../thorp/aws/lib/UploaderLogging.scala | 4 +- .../net/kemitix/thorp/domain/Terminal.scala | 143 +++++++++++++++++- .../kemitix/thorp/domain/TerminalTest.scala | 71 +++++++++ 4 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 domain/src/test/scala/net/kemitix/thorp/domain/TerminalTest.scala diff --git a/aws-api/src/main/scala/net/kemitix/thorp/aws/api/UploadProgressLogging.scala b/aws-api/src/main/scala/net/kemitix/thorp/aws/api/UploadProgressLogging.scala index ad5167b..3f9244e 100644 --- a/aws-api/src/main/scala/net/kemitix/thorp/aws/api/UploadProgressLogging.scala +++ b/aws-api/src/main/scala/net/kemitix/thorp/aws/api/UploadProgressLogging.scala @@ -2,31 +2,23 @@ package net.kemitix.thorp.aws.api import net.kemitix.thorp.aws.api.UploadEvent.RequestEvent import net.kemitix.thorp.domain.SizeTranslation.sizeInEnglish -import net.kemitix.thorp.domain.Terminal.{clearLine, returnToPreviousLine} +import net.kemitix.thorp.domain.Terminal._ import net.kemitix.thorp.domain.{LocalFile, Terminal} -import scala.io.AnsiColor._ - trait UploadProgressLogging { - private val oneHundredPercent = 100 - def logRequestCycle(localFile: LocalFile, - event: RequestEvent, - bytesTransferred: Long): Unit = { + event: RequestEvent, + bytesTransferred: Long): Unit = { val remoteKey = localFile.remoteKey.key val fileLength = localFile.file.length - val consoleWidth = Terminal.width - 2 - val done = ((bytesTransferred.toDouble / fileLength.toDouble) * consoleWidth).toInt - if (done < oneHundredPercent) { - val head = s"$GREEN_B$GREEN#$RESET" * done - val tail = " " * (consoleWidth - done) - val bar = s"[$head$tail]" + if (bytesTransferred < fileLength) { + val bar = progressBar(bytesTransferred, fileLength.toDouble, Terminal.width) val transferred = sizeInEnglish(bytesTransferred) val fileSize = sizeInEnglish(fileLength) - print(s"${clearLine}Uploading $transferred of $fileSize : $remoteKey\n$bar$returnToPreviousLine") + print(s"${eraseLine}Uploading $transferred of $fileSize : $remoteKey\n$bar${cursorUp()}\r") } else - print(clearLine) + print(eraseLine) } } diff --git a/aws-lib/src/main/scala/net/kemitix/thorp/aws/lib/UploaderLogging.scala b/aws-lib/src/main/scala/net/kemitix/thorp/aws/lib/UploaderLogging.scala index 0324782..944c468 100644 --- a/aws-lib/src/main/scala/net/kemitix/thorp/aws/lib/UploaderLogging.scala +++ b/aws-lib/src/main/scala/net/kemitix/thorp/aws/lib/UploaderLogging.scala @@ -2,7 +2,7 @@ package net.kemitix.thorp.aws.lib import cats.effect.IO import net.kemitix.thorp.domain.SizeTranslation.sizeInEnglish -import net.kemitix.thorp.domain.Terminal.clearLine +import net.kemitix.thorp.domain.Terminal._ import net.kemitix.thorp.domain.{LocalFile, Logger} object UploaderLogging { @@ -12,7 +12,7 @@ object UploaderLogging { (implicit logger: Logger): IO[Unit] = { val tryMessage = if (tryCount == 1) "" else s"try $tryCount" val size = sizeInEnglish(localFile.file.length) - logger.info(s"${clearLine}upload:$tryMessage:$size:${localFile.remoteKey.key}") + logger.info(s"${eraseLine}upload:$tryMessage:$size:${localFile.remoteKey.key}") } def logMultiPartUploadFinished(localFile: LocalFile) diff --git a/domain/src/main/scala/net/kemitix/thorp/domain/Terminal.scala b/domain/src/main/scala/net/kemitix/thorp/domain/Terminal.scala index 4188686..8354611 100644 --- a/domain/src/main/scala/net/kemitix/thorp/domain/Terminal.scala +++ b/domain/src/main/scala/net/kemitix/thorp/domain/Terminal.scala @@ -1,15 +1,127 @@ package net.kemitix.thorp.domain +import scala.io.AnsiColor._ + object Terminal { + private val esc = "\u001B" + private val csi = esc + "[" + + /** + * Move the cursor up, default 1 line. + * + * Stops at the edge of the screen. + */ + def cursorUp(lines: Int = 1) = csi + lines + "A" + + /** + * Move the cursor down, default 1 line. + * + * Stops at the edge of the screen. + */ + def cursorDown(lines: Int = 1) = csi + lines + "B" + + /** + * Move the cursor forward, default 1 column. + * + * Stops at the edge of the screen. + */ + def cursorForward(cols: Int = 1) = csi + cols + "C" + + /** + * Move the cursor back, default 1 column, + * + * Stops at the edge of the screen. + */ + def cursorBack(cols: Int = 1) = csi + cols + "D" + + /** + * Move the cursor to the beginning of the line, default 1, down. + */ + def cursorNextLine(lines: Int = 1) = csi + lines + "E" + /** + * Move the cursor to the beginning of the line, default 1, up. + */ + def cursorPrevLine(lines: Int = 1) = csi + lines + "F" + + /** + * Move the cursor to the column on the current line. + */ + def cursorHorizAbs(col: Int) = csi + col + "G" + + /** + * Move the cursor to the position on screen (1,1 is the top-left). + */ + def cursorPosition(row: Int, col: Int) = csi + row + ";" + col + "H" + + /** + * Clear from cursor to end of screen. + */ + val eraseToEndOfScreen = csi + "0J" + + /** + * Clear from cursor to beginning of screen. + */ + val eraseToStartOfScreen = csi + "1J" + + /** + * Clear screen and move cursor to top-left. + * + * On DOS the "2J" command also moves to 1,1, so we force that behaviour for all. + */ + val eraseScreen = csi + "2J" + cursorPosition(1, 1) + + /** + * Clear screen and scrollback buffer then move cursor to top-left. + * + * Anticipate that same DOS behaviour here, and to maintain consistency with {@link #eraseScreen}. + */ + val eraseScreenAndBuffer = csi + "3J" + + /** + * Clears the terminal line to the right of the cursor. + * + * Does not move the cursor. + */ + val eraseLineForward = csi + "0K" + + /** + * Clears the terminal line to the left of the cursor. + * + * Does not move the cursor. + */ + val eraseLineBack= csi + "1K" + /** * Clears the whole terminal line. + * + * Does not move the cursor. */ - val clearLine = "\u001B[2K\r" + val eraseLine = csi + "2K" + /** - * Moves the cursor up one line and back to the start of the line. + * Scroll page up, default 1, lines. */ - val returnToPreviousLine = "\u001B[1A\r" + def scrollUp(lines: Int = 1) = csi + lines + "S" + + /** + * Scroll page down, default 1, lines. + */ + def scrollDown(lines: Int = 1) = csi + lines + "T" + + /** + * Saves the cursor position/state. + */ + val saveCursorPosition = csi + "s" + + /** + * Restores the cursor position/state. + */ + val restoreCursorPosition = csi + "u" + + val enableAlternateBuffer = csi + "?1049h" + + val disableAlternateBuffer = csi + "?1049l" /** * The Width of the terminal, as reported by the COLUMNS environment variable. @@ -25,4 +137,29 @@ object Terminal { .getOrElse(80) } + val subBars = Map( + 0 -> " ", + 1 -> "▏", + 2 -> "▎", + 3 -> "▍", + 4 -> "▌", + 5 -> "▋", + 6 -> "▊", + 7 -> "▉") + + def progressBar(pos: Double, max: Double, width: Int): String = { + val barWidth = width - 2 + val phases = subBars.values.size + val pxWidth = barWidth * phases + val ratio = pos / max + val pxDone = pxWidth * ratio + val fullHeadSize: Int = (pxDone / phases).toInt + val part = (pxDone % phases).toInt + val partial = if (part != 0) subBars.getOrElse(part, "") else "" + val head = ("█" * fullHeadSize) + partial + val tailSize = barWidth - head.length + val tail = " " * tailSize + s"[$head$tail]" + } + } diff --git a/domain/src/test/scala/net/kemitix/thorp/domain/TerminalTest.scala b/domain/src/test/scala/net/kemitix/thorp/domain/TerminalTest.scala new file mode 100644 index 0000000..d2ce137 --- /dev/null +++ b/domain/src/test/scala/net/kemitix/thorp/domain/TerminalTest.scala @@ -0,0 +1,71 @@ +package net.kemitix.thorp.domain + +import scala.io.AnsiColor._ + +import org.scalatest.FunSpec + +class TerminalTest extends FunSpec { + + describe("progressBar") { + describe("width 10 - 0%") { + it("should match") { + val bar = Terminal.progressBar(0d, 10d, 12) + assertResult("[ ]")(bar) + } + } + describe("width 10 - 10%") { + it("should match") { + val bar = Terminal.progressBar(1d, 10d, 12) + assertResult("[█ ]")(bar) + } + } + describe("width 1 - 8/8th") { + it("should match") { + val bar = Terminal.progressBar(8d, 8d, 3) + assertResult("[█]")(bar) + } + } + describe("width 1 - 7/8th") { + it("should match") { + val bar = Terminal.progressBar(7d, 8d, 3) + assertResult("[▉]")(bar) + } + } + describe("width 1 - 6/8th") { + it("should match") { + val bar = Terminal.progressBar(6d, 8d, 3) + assertResult("[▊]")(bar) + } + } + describe("width 1 - 5/8th") { + it("should match") { + val bar = Terminal.progressBar(5d, 8d, 3) + assertResult("[▋]")(bar) + } + } + describe("width 1 - 4/8th") { + it("should match") { + val bar = Terminal.progressBar(4d, 8d, 3) + assertResult("[▌]")(bar) + } + } + describe("width 1 - 3/8th") { + it("should match") { + val bar = Terminal.progressBar(3d, 8d, 3) + assertResult("[▍]")(bar) + } + } + describe("width 1 - 2/8th") { + it("should match") { + val bar = Terminal.progressBar(2d, 8d, 3) + assertResult("[▎]")(bar) + } + } + describe("width 1 - 1/8th") { + it("should match") { + val bar = Terminal.progressBar(1d, 8d, 3) + assertResult("[▏]")(bar) + } + } + } +}