Repository: ajalt/mordant
Pull request #44 (2021-11-14 19:14:44)

Add spinner widget


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6cec9932..007eeef7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,13 @@
 # Changelog
 
-## 2.0.0-beta3
-_2021-06-01_
+## Unreleased
+### Added
+- `Spinner` widget that displays a looping animation
+
+### Fixed
+- Reduced flickering on high frame rate animations
 
+## 2.0.0-beta3
 ### Changed
 - Update Kotlin to 1.5.31
 - Update Colormath to 3.0. If you use and colormath colors directly, you may need to update your imports.
@@ -12,8 +17,6 @@ _2021-06-01_
 - Fixed rendering of markdown image reference links and link content
 
 ## 2.0.0-beta2
-_2021-06-01_
-
 ### Added
 - Published artifacts for macOS
 
@@ -22,8 +25,6 @@ _2021-06-01_
 - All text instances and print functions now default to preformatted whitespace, meaning that spaces and newlines will be preserved. You can explicitly pass `Whitespace.NORMAL` to restore the previous behavior.
 
 ## 2.0.0-beta1
-_2021-03-26_
-
 ### Added
 - `Table.contentToCsv` to render a table's cells to csv format
 - Added support for JavaScript and linux native targets 
@@ -34,8 +35,6 @@ _2021-03-26_
 - Improve terminal capabilities detection
 
 ## 2.0.0-alpha2
-_2021-02-02_
-
 ### Added
 - `Terminal.progressAnimation` builder to create a customizable progress bar animation
 - Improved cursor APIs and added ability to produce cursor ANSI codes as a string
@@ -45,8 +44,6 @@ _2021-02-02_
 - Rework theming system to simplify customization
 
 ## 2.0.0-alpha1
-_2019-11-01_
-
 Mordant 2.0 is a rewrite that retains the simple APIs of Mordant 1.0, and adds support for rendering
 complex widgets.
 
@@ -63,26 +60,18 @@ complex widgets.
   capabilities.
 
 ## 1.2.1
-_2019-03-17_
-
 ### Changed
 - Improve support for color detection in IntelliJ and VS Code terminals
 
 ## 1.2.0
-_2018-08-19_
-
 ### Added
 - Add functions for generating ANSI cursor movement
 - Add ability to generate ANSI color codes from any colormath color object
 - Update colormath to 1.2.0
 
 ## 1.1.0
-_2018-07-15_
-
 ### Added
 - Add support for XYZ and LAB color spaces
 
 ## 1.0.0
-_2017-09-24_
-
 - Initial Release
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/BlankWidgetWrapper.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/BlankWidgetWrapper.kt
new file mode 100644
index 00000000..7470817a
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/BlankWidgetWrapper.kt
@@ -0,0 +1,13 @@
+package com.github.ajalt.mordant.internal
+
+import com.github.ajalt.mordant.rendering.*
+import com.github.ajalt.mordant.terminal.Terminal
+
+internal class BlankWidgetWrapper(private val inner: Widget) : Widget {
+    override fun measure(t: Terminal, width: Int): WidthRange = inner.measure(t, width)
+
+    override fun render(t: Terminal, width: Int): Lines {
+        val orig = inner.render(t, width)
+        return Lines(orig.lines.map { Line(listOf(Span.space(it.lineWidth))) })
+    }
+}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
index 85a9f80a..b00aeba0 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
@@ -4,6 +4,8 @@ import com.github.ajalt.mordant.terminal.*
 
 internal expect class AtomicInt(initial: Int) {
     fun getAndIncrement(): Int
+    fun get(): Int
+    fun set(value: Int)
 }
 
 internal expect fun getEnv(key: String): String?
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressCell.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressCell.kt
index 813f2dac..4d040361 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressCell.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressCell.kt
@@ -1,5 +1,6 @@
 package com.github.ajalt.mordant.widgets
 
+import com.github.ajalt.mordant.internal.BlankWidgetWrapper
 import com.github.ajalt.mordant.internal.formatMultipleWithSiSuffixes
 import com.github.ajalt.mordant.internal.formatWithSiSuffix
 import com.github.ajalt.mordant.rendering.TextStyle
@@ -44,6 +45,20 @@ internal class TextProgressCell(private val text: Text) : ProgressCell {
     override fun ProgressState.makeWidget(): Widget = text
 }
 
+internal class SpinnerProgressCell(
+    private val frames: List<Widget>,
+    private val frameRate: Int,
+) : ProgressCell {
+    override val animationRate: AnimationRate get() = AnimationRate.ANIMATION
+    override val columnWidth: ColumnWidth get() = ColumnWidth.Auto
+    override fun ProgressState.makeWidget(): Widget {
+        val frame = elapsedSeconds * frameRate
+        val spinner = Spinner(frames, initial = frame.toInt())
+        if (total != null && completed >= total) return BlankWidgetWrapper(spinner)
+        return spinner
+    }
+}
+
 internal class PercentageProgressCell : ProgressCell {
     override val animationRate: AnimationRate get() = AnimationRate.TEXT
     override val columnWidth: ColumnWidth get() = ColumnWidth.Fixed(4)
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressLayout.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressLayout.kt
index 2e6abfc6..fa25f5a2 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressLayout.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/ProgressLayout.kt
@@ -55,6 +55,16 @@ open class ProgressBuilder internal constructor() {
         cells += EtaProgressCell(prefix, style)
     }
 
+    /**
+     * Add a [Spinner] to this layout.
+     *
+     * @param spinner The spinner to display
+     * @param frameRate The number of times per second to advance the spinner's displayed frame
+     */
+    fun spinner(spinner: Spinner, frameRate: Int = 8) {
+        cells += SpinnerProgressCell(spinner.frames, frameRate)
+    }
+
     internal fun build(): ProgressLayout {
         return ProgressLayout(cells, padding)
     }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt
new file mode 100644
index 00000000..bfef6282
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt
@@ -0,0 +1,81 @@
+package com.github.ajalt.mordant.widgets
+
+import com.github.ajalt.mordant.internal.AtomicInt
+import com.github.ajalt.mordant.internal.DEFAULT_STYLE
+import com.github.ajalt.mordant.rendering.Lines
+import com.github.ajalt.mordant.rendering.TextStyle
+import com.github.ajalt.mordant.rendering.Widget
+import com.github.ajalt.mordant.rendering.WidthRange
+import com.github.ajalt.mordant.terminal.Terminal
+
+/**
+ * A widget that will loop through a fixed list of frames.
+ *
+ * The widget will render the same frame until [tick] is set or [advanceTick] is called, at which point the next frame
+ * will render. The animation will loop when [tick] is larger than the number frames.
+ *
+ * To reduce the speed of the animation, increase the [duration].
+ *
+ * @property frames the frames of the animation.
+ * @property duration the number of [ticks][tick] that each frame of the spinner should show for. This defaults to 1,
+ *   which will cause a new frame to display every time [advanceTick] is called.
+ * @param initial the starting tick value.
+ */
+class Spinner(
+    internal val frames: List<Widget>,
+    private val duration: Int = 1,
+    initial: Int = 0,
+) : Widget {
+    companion object {
+        /**
+         * Create a spinner with the following frames:
+         *
+         * `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
+         */
+        fun Dots(style: TextStyle = DEFAULT_STYLE, duration: Int = 1, initial: Int = 0): Spinner {
+            return Spinner("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", style, duration, initial)
+        }
+
+        /**
+         * Create a spinner with the following frames:
+         *
+         * `|/-\`
+         *
+         * These frames only use ASCII characters.
+         */
+        fun Lines(style: TextStyle = DEFAULT_STYLE, duration: Int = 1, initial: Int = 0): Spinner {
+            return Spinner("|/-\\", style, duration, initial)
+        }
+    }
+
+    /**
+     * Construct a [Spinner] from a string, where each character in the string is one frame.
+     */
+    constructor(frames: String, style: TextStyle = DEFAULT_STYLE, duration: Int = 1, initial: Int = 0) :
+            this(frames.map { Text(style(it.toString())) }, duration, initial)
+
+    private val _tick = AtomicInt(initial)
+
+    /**
+     * The current frame number.
+     *
+     * This may be larger than the number of frames, in which case the animation will loop.
+     */
+    var tick: Int
+        get() = _tick.get()
+        set(value) {
+            _tick.set(value)
+        }
+
+    /**
+     * Increment the [tick] value by one and return the new value.
+     */
+    fun advanceTick(): Int {
+        return _tick.getAndIncrement() + 1
+    }
+
+    private val currentFrame get() = frames[(tick / duration) % frames.size]
+
+    override fun measure(t: Terminal, width: Int): WidthRange = currentFrame.measure(t, width)
+    override fun render(t: Terminal, width: Int): Lines = currentFrame.render(t, width)
+}
diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index a2f25506..cda38bcd 100644
--- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -10,6 +10,14 @@ internal actual class AtomicInt actual constructor(initial: Int) {
     actual fun getAndIncrement(): Int {
         return backing++
     }
+
+    actual fun get(): Int {
+        return backing
+    }
+
+    actual fun set(value: Int) {
+        backing = value
+    }
 }
 
 
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index c94ed483..f2d2cfa9 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -11,6 +11,14 @@ internal actual class AtomicInt actual constructor(initial: Int) {
     actual fun getAndIncrement(): Int {
         return backing.getAndIncrement()
     }
+
+    actual fun get(): Int {
+        return backing.get()
+    }
+
+    actual fun set(value: Int) {
+        backing.set(value)
+    }
 }
 
 // We have to shell out to another program on JVM, which takes ~10ms for stty and ~100ms for powershell
diff --git a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index 61caa429..8dde3ef5 100644
--- a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -17,6 +17,14 @@ internal actual class AtomicInt actual constructor(initial: Int) {
     actual fun getAndIncrement(): Int {
         return backing.addAndGet(1) - 1
     }
+
+    actual fun get(): Int {
+        return backing.value
+    }
+
+    actual fun set(value: Int) {
+        backing.value = value
+    }
 }
 
 internal actual fun terminalSizeDetectionIsFast(): Boolean = true
diff --git a/samples/progress/src/main/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/progress/src/main/kotlin/com/github/ajalt/mordant/samples/main.kt
index b2dd2980..16ac777e 100644
--- a/samples/progress/src/main/kotlin/com/github/ajalt/mordant/samples/main.kt
+++ b/samples/progress/src/main/kotlin/com/github/ajalt/mordant/samples/main.kt
@@ -1,7 +1,9 @@
 package com.github.ajalt.mordant.samples
 
 import com.github.ajalt.mordant.animation.progressAnimation
+import com.github.ajalt.mordant.rendering.TextColors.brightBlue
 import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.widgets.Spinner
 
 fun main() {
     val terminal = Terminal()
@@ -10,6 +12,7 @@ fun main() {
     terminal.info.updateTerminalSize()
 
     val progress = terminal.progressAnimation {
+        spinner(Spinner.Dots(brightBlue))
         text("my-file.bin")
         percentage()
         progressBar()


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SpinnerTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SpinnerTest.kt
new file mode 100644
index 00000000..2325dd93
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SpinnerTest.kt
@@ -0,0 +1,37 @@
+package com.github.ajalt.mordant.widgets
+
+import com.github.ajalt.mordant.rendering.TextColors.red
+import com.github.ajalt.mordant.test.RenderingTest
+import io.kotest.matchers.shouldBe
+import kotlin.test.Test
+
+class SpinnerTest : RenderingTest() {
+    @Test
+    fun dots() = doTest(Spinner.Dots(), "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
+
+    @Test
+    fun line() = doTest(Spinner.Lines(), "|", "/", "-", "\\")
+
+    @Test
+    fun custom() = doTest(Spinner("ab"), "a", "b", "a", "b")
+
+    @Test
+    fun duration() = doTest(Spinner("12", duration = 2), "1", "1", "2", "2", "1", "1", "2", "2")
+
+    @Test
+    fun lineStyle() = doTest(Spinner.Lines(red), red("|"), red("/"), red("-"), red("\\"))
+
+    @Test
+    fun initialTick() = doTest(Spinner.Lines(initial = 2), "-", "\\", "|", "/")
+
+    private fun doTest(
+        spinner: Spinner,
+        vararg expected: String,
+    ) {
+        for (ex in expected) {
+            checkRender(spinner, ex)
+            val t = spinner.tick
+            spinner.advanceTick() shouldBe (t + 1)
+        }
+    }
+}