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
This commit is contained in:
Paul Campbell 2019-06-20 22:58:21 +01:00 committed by GitHub
parent 0a92667d3c
commit eec79066f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 220 additions and 20 deletions

View file

@ -2,31 +2,23 @@ package net.kemitix.thorp.aws.api
import net.kemitix.thorp.aws.api.UploadEvent.RequestEvent import net.kemitix.thorp.aws.api.UploadEvent.RequestEvent
import net.kemitix.thorp.domain.SizeTranslation.sizeInEnglish 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 net.kemitix.thorp.domain.{LocalFile, Terminal}
import scala.io.AnsiColor._
trait UploadProgressLogging { trait UploadProgressLogging {
private val oneHundredPercent = 100
def logRequestCycle(localFile: LocalFile, def logRequestCycle(localFile: LocalFile,
event: RequestEvent, event: RequestEvent,
bytesTransferred: Long): Unit = { bytesTransferred: Long): Unit = {
val remoteKey = localFile.remoteKey.key val remoteKey = localFile.remoteKey.key
val fileLength = localFile.file.length val fileLength = localFile.file.length
val consoleWidth = Terminal.width - 2 if (bytesTransferred < fileLength) {
val done = ((bytesTransferred.toDouble / fileLength.toDouble) * consoleWidth).toInt val bar = progressBar(bytesTransferred, fileLength.toDouble, Terminal.width)
if (done < oneHundredPercent) {
val head = s"$GREEN_B$GREEN#$RESET" * done
val tail = " " * (consoleWidth - done)
val bar = s"[$head$tail]"
val transferred = sizeInEnglish(bytesTransferred) val transferred = sizeInEnglish(bytesTransferred)
val fileSize = sizeInEnglish(fileLength) 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 } else
print(clearLine) print(eraseLine)
} }
} }

View file

@ -2,7 +2,7 @@ package net.kemitix.thorp.aws.lib
import cats.effect.IO import cats.effect.IO
import net.kemitix.thorp.domain.SizeTranslation.sizeInEnglish 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} import net.kemitix.thorp.domain.{LocalFile, Logger}
object UploaderLogging { object UploaderLogging {
@ -12,7 +12,7 @@ object UploaderLogging {
(implicit logger: Logger): IO[Unit] = { (implicit logger: Logger): IO[Unit] = {
val tryMessage = if (tryCount == 1) "" else s"try $tryCount" val tryMessage = if (tryCount == 1) "" else s"try $tryCount"
val size = sizeInEnglish(localFile.file.length) 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) def logMultiPartUploadFinished(localFile: LocalFile)

View file

@ -1,15 +1,127 @@
package net.kemitix.thorp.domain package net.kemitix.thorp.domain
import scala.io.AnsiColor._
object Terminal { 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. * 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. * The Width of the terminal, as reported by the COLUMNS environment variable.
@ -25,4 +137,29 @@ object Terminal {
.getOrElse(80) .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]"
}
} }

View file

@ -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)
}
}
}
}