Repository: ajalt/mordant (kotlin)

Issue:
Progress bar: length must be 2 or greater, was 1
See https://github.com/russellbanks/Komac/issues/102:

GitHub runners sometimes produce this error with progress bars:

```
   Exception in thread "Timer-0" java.lang.IllegalArgumentException: length must be 2 or greater, was 1
  	at com.github.ajalt.colormath.transform.InterpolateKt.sequence(Interpolate.kt:158)
  	at com.github.ajalt.mordant.widgets.ProgressBar.makeComplete(ProgressBar.kt:107)
  	at com.github.ajalt.mordant.widgets.ProgressBar.render(ProgressBar.kt:86)
  	at com.github.ajalt.mordant.widgets.Padded.render(Padding.kt:99)
  	at com.github.ajalt.mordant.table.TableRenderer.<init>(Table.kt:246)
  	at com.github.ajalt.mordant.table.TableImpl.render(Table.kt:121)
  	at com.github.ajalt.mordant.rendering.Widget$DefaultImpls.render$default(Widget.kt:7)
  	at com.github.ajalt.mordant.animation.Animation.update(Animation.kt:76)
  	at com.github.ajalt.mordant.animation.ProgressAnimation.tick(ProgressAnimation.kt:164)
  	at com.github.ajalt.mordant.animation.ProgressAnimation.access$tick(ProgressAnimation.kt:91)
  	at com.github.ajalt.mordant.animation.ProgressAnimation$start$1.invoke(ProgressAnimation.kt:154)
  	at com.github.ajalt.mordant.animation.ProgressAnimation$start$1.invoke(ProgressAnimation.kt:153)
  	at com.github.ajalt.mordant.animation.JvmTicker$start$$inlined$timer$default$1.run(Timer.kt:149)
  	at java.base/java.util.TimerThread.mainLoop(Timer.java:566)
  	at java.base/java.util.TimerThread.run(Timer.java:516)
```

```kotlin
fun getDownloadProgressBar(url: Url, terminal: Terminal): ProgressAnimation {
    return terminal.progressAnimation {
        url.getFileName()?.let { text(it) }
        percentage()
        progressBar()
        completed()
        speed("B/s")
        timeRemaining()
    }
}
```

```kotlin
getDownloadProgressBar(url, this).run {
    start()
    prepareGet(url).execute { httpResponse ->
        val channel: ByteReadChannel = httpResponse.body()
        while (!channel.isClosedForRead) {
            val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
            while (packet.isNotEmpty) {
                file.appendBytes(packet.readBytes())
                update(file.length(), httpResponse.contentLength())
            }
        }
    }
    clear()
}
```

Pull request description:
Fixes #90

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c6f186ba..4296843b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
 ## Unreleased
 ### Fixed
 - `OverflowWrap` is now properly ignored when using a non-wrapping `Whitespace` value. 
+- Fix exception in ProgressBar when its width is 1 character [(#75)](https://github.com/ajalt/mordant/issues/75)
 
 ## 2.0.0-beta11
 ### Added
@@ -12,7 +13,7 @@
 - Update Kotlin to 1.8.0
 
 ### Removed
-- Removed JS/Legacy publication. The JS target only publishes artifact build with the IR compiler.
+- Removed JS/Legacy publication. The JS target only publishes artifacts built with the IR compiler.
 
 ## 2.0.0-beta10
 ### Changed
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressBar.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressBar.kt
index 0f55f9fe2..70eb24470 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressBar.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressBar.kt
@@ -12,9 +12,17 @@ import com.github.ajalt.mordant.internal.ThemeStyle
 import com.github.ajalt.mordant.rendering.*
 import com.github.ajalt.mordant.terminal.Terminal
 
+/**
+ * A horizontal bar that tracks progress.
+ *
+ * The bar has three states:
+ *
+ * - indeterminate: the bar will draw its full width and show a pulsing animation based on `pulsePosition`
+ * - in progress: the bar will draw a width proportional to `fractionComplete`, or `completed / total`
+ * - finished: the bar will draw its full width with no pulse.
+ */
 class ProgressBar private constructor(
-    private var total: Long,
-    private var completed: Long,
+    private val fractionComplete: Float,
     private var indeterminate: Boolean,
     private val width: Int?,
     private val pulsePosition: Float,
@@ -28,6 +36,22 @@ class ProgressBar private constructor(
     private val finishedStyle: ThemeStyle,
     private val indeterminateStyle: ThemeStyle,
 ) : Widget {
+    /**
+     * @param total The bar draws the fraction complete as [completed] `/` [total]
+     * @param completed The bar draws the fraction complete as [completed] `/` [total]
+     * @param indeterminate If true, [total] and [completed] are ignored and the bar draws in an indeterminate state
+     * @param width The width in characters for this widget
+     * @param pulsePosition When [indeterminate] is true, the pulse animation is drawn at a fraction along the bar calculated by `pulsePosition % 1.0`
+     * @param showPulse (theme flag: "progressbar.pulse") If false, never draw the pulse animation in the indeterminate state.
+     * @param pendingChar (theme string: "progressbar.pending") The character to use to draw the pending portion of the bar in the active state.
+     * @param separatorChar (theme string: "progressbar.separator") The character to draw in between the competed and pending bar in the active state.
+     * @param completeChar (theme string: "progressbar.complete") The character to use to draw the completed portion of the bar in the active state.
+     * @param pendingStyle(theme style: "progressbar.pending") The style to use for the [pendingChar]s
+     * @param separatorStyle (theme style: "progressbar.separator") The style to use for the [separatorChar]
+     * @param completeStyle (theme style: "progressbar.complete") The style to use for the [completeChar] when [completed] < [total]
+     * @param finishedStyle (theme style: "progressbar.complete") The style to use for the [completeChar] when [total] <= [completed]
+     * @param indeterminateStyle e (theme style: "progressbar.separator") The style to use when [indeterminate] is true
+     */
     constructor(
         total: Long = 100,
         completed: Long = 0,
@@ -44,7 +68,55 @@ class ProgressBar private constructor(
         finishedStyle: TextStyle? = null,
         indeterminateStyle: TextStyle? = null,
     ) : this(
-        total, completed, indeterminate, width, pulsePosition,
+        if (total <= 0) 0f else completed.toFloat() / total,
+        indeterminate,
+        width,
+        pulsePosition,
+        showPulse,
+        pendingChar,
+        separatorChar,
+        completeChar,
+        pendingStyle,
+        separatorStyle,
+        completeStyle,
+        finishedStyle,
+        indeterminateStyle
+    )
+
+    /**
+     * @param fractionComplete A number between 0 and 1, with 0 drawing all [pendingChar]s, and 1 drawing all [completeChar]s
+     * @param indeterminate If true, [fractionComplete] is ignored and the bar draws in an indeterminate state
+     * @param width The width in characters for this widget
+     * @param pulsePosition When [indeterminate] is true, the pulse animation is drawn at a fraction along the bar calculated by `pulsePosition % 1.0`
+     * @param showPulse (theme flag: "progressbar.pulse") If false, never draw the pulse animation in the indeterminate state.
+     * @param pendingChar (theme string: "progressbar.pending") The character to use to draw the pending portion of the bar in the active state.
+     * @param separatorChar (theme string: "progressbar.separator") The character to draw in between the competed and pending bar in the active state.
+     * @param completeChar (theme string: "progressbar.complete") The character to use to draw the completed portion of the bar in the active state.
+     * @param pendingStyle(theme style: "progressbar.pending") The style to use for the [pendingChar]s
+     * @param separatorStyle (theme style: "progressbar.separator") The style to use for the [separatorChar]
+     * @param completeStyle (theme style: "progressbar.complete") The style to use for the [completeChar] when [fractionComplete] < 1
+     * @param finishedStyle (theme style: "progressbar.complete") The style to use for the [completeChar] when [fractionComplete] == 1
+     * @param indeterminateStyle e (theme style: "progressbar.separator") The style to use when [indeterminate] is true
+     */
+    constructor(
+        fractionComplete: Float = 0f,
+        indeterminate: Boolean = false,
+        width: Int? = null,
+        pulsePosition: Float = 0f,
+        showPulse: Boolean? = null,
+        pendingChar: String? = null,
+        separatorChar: String? = null,
+        completeChar: String? = null,
+        pendingStyle: TextStyle? = null,
+        separatorStyle: TextStyle? = null,
+        completeStyle: TextStyle? = null,
+        finishedStyle: TextStyle? = null,
+        indeterminateStyle: TextStyle? = null,
+    ) : this(
+        fractionComplete,
+        indeterminate,
+        width,
+        pulsePosition,
         ThemeFlag.of("progressbar.pulse", showPulse),
         ThemeString.of("progressbar.pending", pendingChar),
         ThemeString.of("progressbar.separator", separatorChar),
@@ -60,8 +132,6 @@ class ProgressBar private constructor(
         require(width == null || width > 0) { "width must be greater than 0" }
     }
 
-    val percentComplete: Double get() = if (total == 0L) 0.0 else (completed.toDouble() / total).coerceIn(0.0, 1.0)
-
     private fun width(renderWidth: Int) = width ?: renderWidth
 
     override fun measure(t: Terminal, width: Int): WidthRange {
@@ -71,7 +141,10 @@ class ProgressBar private constructor(
 
     override fun render(t: Terminal, width: Int): Lines {
         val w = width(width)
-        val completedLength = if (indeterminate) w else (percentComplete * w).toInt()
+        val completedLength = when {
+            indeterminate -> w
+            else -> (fractionComplete.coerceIn(0f..1f) * w).toInt()
+        }
         val completeStyle = when {
             indeterminate -> indeterminateStyle[t]
             else -> completeStyle[t]
@@ -81,7 +154,9 @@ class ProgressBar private constructor(
             return makeLine(listOfNotNull(segmentText(completeChar[t], w, finishedStyle[t])))
         }
 
-        val sep = if (completedLength in 1 until w) segmentText(separatorChar[t], 1, separatorStyle[t]) else null
+        val sep = if (completedLength in 1 until w) segmentText(
+            separatorChar[t], 1, separatorStyle[t]
+        ) else null
         val sepLength = sep?.cellWidth ?: 0
         val complete = makeComplete(t, w, completedLength, completeChar[t], completeStyle)
         val pending = segmentText(pendingChar[t], w - completedLength - sepLength, pendingStyle[t])
@@ -89,12 +164,18 @@ class ProgressBar private constructor(
         return makeLine(complete + listOfNotNull(sep, pending))
     }
 
-    private fun makeComplete(t: Terminal, width: Int, barLength: Int, char: String, style: TextStyle): Line {
+    private fun makeComplete(
+        t: Terminal,
+        width: Int,
+        barLength: Int,
+        char: String,
+        style: TextStyle,
+    ): Line {
         if (barLength == 0) return EMPTY_LINE
 
         val color = style.color
 
-        if (color == null || !showPulse[t]) {
+        if (color == null || !showPulse[t] || width < 2) {
             return Line(listOfNotNull(segmentText(char, barLength, style)))
         }
 
@@ -104,7 +185,8 @@ class ProgressBar private constructor(
             stop(color, p - .5)
             stop(RGB(1, 1, 1), p - .25)
             stop(color, p)
-        }.sequence(width).take(barLength)
+        }.sequence(width)
+            .take(barLength)
             // as an optimization, convert to RGBInt so that adjacent identical styles can be joined
             // even if the float values are slightly different
             .map { Span.word(char, TextStyle(it.toSRGB().toRGBInt())) }


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressBarTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressBarTest.kt
index b27c9ba12..cac47bb8d 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressBarTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressBarTest.kt
@@ -86,11 +86,19 @@ class ProgressBarTest : RenderingTest() {
         "${CSI}38;2;97;175;239m━━━━━━━━━━${CSI}39m"
     )
 
-    private fun doPulseTest(pulsePosition: Float, expected: String) {
+    @Test
+    @JsName("narrow_width")
+    fun `narrow width`() = doPulseTest(
+        pulsePosition = 1f,
+        "${CSI}38;2;97;175;239m━${CSI}39m",
+        width =  1
+    )
+
+    private fun doPulseTest(pulsePosition: Float, expected: String, width: Int = 10) {
         checkRender(
             ProgressBar(indeterminate = true, pulsePosition = pulsePosition),
             expected,
-            width = 10
+            width = width
         )
     }