Repository: ajalt/mordant (kotlin)

Issue:
Provide a way to disconnect animation TerminalInterceptor
I am working on a CLI with output similar to a docker pull, where I have several lines of status info that updates as operations progress. I am using a custom Animation subclass to handle rendering and updating this. Later, after this phase finishes, I want to continue printing other information, but leave the last state of the animation in place.

Currently, if I print new lines after the animations without first clearing them, the animations will re-print after the new lines.
What I would like to be able to do is to stop or disconnect the animation so that the last text it printed remains, and does not re-print or clear.

Pull request:
Fixes #95

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de1cf424a..20557193f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
 # Changelog
 
 ## Unreleased
+### Added
+- Add `Animation.stop()` to stop an animation without clearing it. [(#95)](https://github.com/ajalt/mordant/issues/95)
+- Animations now support resuming after a call to `stop` or `clear`. [(#94)](https://github.com/ajalt/mordant/issues/94)
+
 ### Fixed
 - Fix `TerminalInfo.interactive` not including `outputInteractive`
 
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
index 437708747..16fe3163e 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
@@ -7,22 +7,25 @@ import com.github.ajalt.mordant.widgets.RawWidget
 import com.github.ajalt.mordant.widgets.Text
 
 /**
- * An Animation renders a widget to the screen each time [update] is called, clearing the render from the previous call.
+ * An Animation renders a widget to the screen each time [update] is called, clearing the render
+ * from the previous call.
  *
- * If you [print][Terminal.print] anything in between calls to [update], the rendered widget will be cleared, before the
- * print, then rendered again below it.
+ * If you [print][Terminal.print] anything in between calls to [update], the rendered widget will be
+ * cleared, before the print, then rendered again below it.
  *
- * Note that [Animation] doesn't render the widget automatically: you need to call [update] yourself when your data
- * changes. If your terminal is not [interactive][TerminalInfo.interactive], the animation will not render anything.
+ * Note that [Animation] doesn't render the widget automatically: you need to call [update] yourself
+ * when your data changes. If your terminal is not [interactive][TerminalInfo.interactive], the
+ * animation will not render anything.
  *
- * You can create instances of Animations with [animation], [textAnimation], and `progressAnimation` (on JVM), or by creating a
- * subclass.
+ * You can create instances of Animations with [animation], [textAnimation], and `progressAnimation`
+ * (on JVM), or by creating a subclass.
  */
 @OptIn(ExperimentalTerminalApi::class)
 abstract class Animation<T>(private val terminal: Terminal) {
     private var size: Pair<Int, Int>? = null
     private var text: String? = null
     private var needsClear = false
+    private var interceptorInstalled = false
 
     // Don't move the cursor the first time the animation is drawn
     private var firstDraw = true
@@ -30,12 +33,8 @@ abstract class Animation<T>(private val terminal: Terminal) {
     private val interceptor: TerminalInterceptor = TerminalInterceptor { req ->
         text?.let { t ->
             PrintRequest(text = buildString {
-                if (req.text.isNotEmpty()) {
-                    needsClear = true
-                }
-
                 if (!firstDraw) {
-                    getClear()?.let { append(it) }
+                    getCursorMoves(needsClear || req.text.isNotEmpty())?.let { append(it) }
                 }
                 firstDraw = false
                 if (req.text.isNotEmpty()) {
@@ -46,33 +45,45 @@ abstract class Animation<T>(private val terminal: Terminal) {
         } ?: req
     }
 
-    init {
-        if (terminal.info.outputInteractive) {
-            terminal.addInterceptor(interceptor)
-        }
-    }
-
     protected abstract fun renderData(data: T): Widget
 
+    /**
+     * Stop this animation and remove it from the screen.
+     *
+     * Future calls to [update] will cause the animation to resume.
+     */
     fun clear() {
-        needsClear = true
-        getClear()?.let {
-            text = null
-            terminal.removeInterceptor(interceptor)
-            terminal.print(RawWidget(it))
-        }
+        stop()
+        getCursorMoves(clearScreen = true)?.let { terminal.print(RawWidget(it)) }
+        size = null
     }
 
-    private fun getClear(): String? {
-        val (height, _) = size ?: return null
-        return terminal.cursor.getMoves {
-            startOfLine()
-            up(height)
-            if (needsClear) clearScreenAfterCursor()
-        }
+    /**
+     * Stop this animation without removing it from the screen.
+     *
+     * Anything printed to the terminal after this call will be printed below this last frame of
+     * this animation.
+     *
+     * Future calls to [update] will cause the animation to start again.
+     */
+    fun stop() {
+        if (interceptorInstalled) terminal.removeInterceptor(interceptor)
+        interceptorInstalled = false
+        firstDraw = true
+        text = null
     }
 
+    /**
+     * Update the data for this animation.
+     *
+     * This will cause the existing animation frame to be cleared and the new frame rendered in its
+     * place.
+     */
     fun update(data: T) {
+        if (!interceptorInstalled && terminal.info.outputInteractive) {
+            terminal.addInterceptor(interceptor)
+        }
+        interceptorInstalled = true
         val rendered = renderData(data).render(terminal)
         val height = rendered.height
         val width = rendered.width
@@ -83,6 +94,15 @@ abstract class Animation<T>(private val terminal: Terminal) {
         // Print an empty renderable to trigger our interceptor, which will add the rendered text
         terminal.print(EmptyWidget)
     }
+
+    private fun getCursorMoves(clearScreen: Boolean): String? {
+        val (height, _) = size ?: return null
+        return terminal.cursor.getMoves {
+            startOfLine()
+            up(height)
+            if (clearScreen) clearScreenAfterCursor()
+        }
+    }
 }
 
 /**
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
index e4d868614..0bf66168f 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
@@ -19,6 +19,8 @@ internal class StdoutTerminalInterface private constructor(
     ) : this(TerminalDetection.detectTerminal(false, ansiLevel, width, height, hyperlinks, interactive),
         TerminalDetection.detectTerminal(true, ansiLevel, width, height, hyperlinks, interactive), false)
 
+    constructor(): this(null, null, null, null, null)
+
     override fun completePrintRequest(request: PrintRequest) {
         if (useStdErr) {
             printStderr(request.text, request.trailingLinebreak)
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt
index bbf4d4936..08b75e094 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt
@@ -51,7 +51,11 @@ class Terminal private constructor(
         hyperlinks: Boolean? = null,
         tabWidth: Int = 8,
         interactive: Boolean? = null,
-    ) : this(theme, tabWidth, StdoutTerminalInterface(ansiLevel, width, height, hyperlinks, interactive))
+    ) : this(
+        theme,
+        tabWidth,
+        StdoutTerminalInterface(ansiLevel, width, height, hyperlinks, interactive)
+    )
 
     /**
      * @param theme The theme to use for widgets and styles like [success]
@@ -59,10 +63,10 @@ class Terminal private constructor(
      * @param terminalInterface The [TerminalInterface] to use to read and write
      */
     constructor(
-         theme: Theme = Theme.Default,
-         tabWidth: Int = 8,
-         terminalInterface: TerminalInterface = StdoutTerminalInterface(null, null, null, null, null),
-    ): this(theme, tabWidth, terminalInterface, mutableListOf(), Any())
+        theme: Theme = Theme.Default,
+        tabWidth: Int = 8,
+        terminalInterface: TerminalInterface = StdoutTerminalInterface(),
+    ) : this(theme, tabWidth, terminalInterface, mutableListOf(), Any())
 
     /**
      * The terminal capabilities that were detected or set in the constructor.
@@ -83,7 +87,8 @@ class Terminal private constructor(
      *
      * If the terminal is not interactive, all of the cursor functions are no-ops.
      */
-    val cursor: TerminalCursor = if (info.interactive) makePrintingTerminalCursor(this) else DisabledTerminalCursor
+    val cursor: TerminalCursor =
+        if (info.interactive) makePrintingTerminalCursor(this) else DisabledTerminalCursor
 
     /**
      * Print a line styled with the theme's [success][Theme.success] style.
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt
index 868d00256..508ab3b60 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt
@@ -4,60 +4,116 @@ import com.github.ajalt.mordant.internal.CSI
 import com.github.ajalt.mordant.widgets.RawWidget
 
 interface TerminalCursor {
+    /**
+     * Show the cursor
+     */
     fun show()
+
+    /**
+     * Hide the cursor
+     *
+     * @param showOnExit By default, the cursor will be shown again when the program terminates. If
+     *   false, the cursor will remain hidden
+     */
     fun hide(showOnExit: Boolean = true)
+
+    /**
+     * Print the movement commands called in [movements].
+     *
+     * If the terminal is not interactive, this call does nothing.
+     */
     fun move(movements: CursorMovements.() -> Unit)
+
+    /**
+     * Return a string contianing the ANSI codes for the movements commands called in [movements].
+     *
+     * If the terminal is not interactive, this will always return an empty string.
+     */
     fun getMoves(movements: CursorMovements.() -> Unit): String
 }
 
 interface CursorMovements {
     /**
-     * Print an ANSI code to move the cursor up [count] cells.
+     * Move the cursor up [count] cells.
      *
-     * If ANSI codes are not supported, or [count] is 0, nothing is printed.
+     * If [count] is 0, this call does nothing.
      * If [count] is negative, the cursor will be moved down instead.
      */
     fun up(count: Int)
 
     /**
-     * Print an ANSI code to move the cursor down [count] cells.
+     * Move the cursor down [count] cells.
      *
-     * If ANSI codes are not supported, or [count] is 0, nothing is printed.
+     * If [count] is 0, this call does nothing.
      * If [count] is negative, the cursor will be moved up instead.
      */
     fun down(count: Int)
 
     /**
-     * Print an ANSI code to move the cursor right [count] cells.
+     * Move the cursor right [count] cells.
      *
-     * If ANSI codes are not supported, or [count] is 0, nothing is printed.
+     * If [count] is 0, this call does nothing.
      * If [count] is negative, the cursor will be moved left instead.
      */
     fun right(count: Int)
 
     /**
-     * Print an ANSI code to move the cursor left [count] cells.
+     * Move the cursor left [count] cells.
      *
-     * If ANSI codes are not supported, or [count] is 0, nothing is printed.
+     * If [count] is 0, this call does nothing.
      * If [count] is negative, the cursor will be moved right instead.
      */
     fun left(count: Int)
 
+    /** Move the cursor to the start of the current line */
     fun startOfLine()
 
-    // TODO: docs
     /**
-     * [x] and [y] are 0-indexed
+     * Set the cursor to an absolute position.
+     *
+     * [x] and [y] are 0-indexed, with (0, 0) being the top-left corner.
      */
     fun setPosition(x: Int, y: Int)
 
+    /**
+     * Clear the entire terminal screen.
+     */
     fun clearScreen()
+
+    /**
+     * Clear the screen before the cursor, leaving anything after the cursor unchanged.
+     */
     fun clearScreenBeforeCursor()
+
+    /**
+     * Clear the screen after the cursor, leaving anything before the cursor unchanged.
+     */
     fun clearScreenAfterCursor()
+
+
+    /**
+     * Clear the current line.
+     */
     fun clearLine()
+
+    /**
+     * Clear the line before the cursor, leaving anything after the cursor unchanged.
+     */
     fun clearLineBeforeCursor()
+
+    /**
+     * Clear the line after the cursor, leaving anything after the cursor unchanged.
+     */
     fun clearLineAfterCursor()
+
+    /**
+     * Save the current cursor position. Restore it with [restorePosition]
+     */
     fun savePosition()
+
+    /**
+     * Move the cursor to the position saved with a prior [savePosition] call.
+     */
     fun restorePosition()
 }
 


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt
new file mode 100644
index 000000000..039e03ef1
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt
@@ -0,0 +1,65 @@
+package com.github.ajalt.mordant.animation
+
+import com.github.ajalt.mordant.terminal.ExperimentalTerminalApi
+import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.terminal.TerminalRecorder
+import io.kotest.matchers.shouldBe
+import kotlin.js.JsName
+import kotlin.test.Test
+
+@OptIn(ExperimentalTerminalApi::class)
+class AnimationTest {
+    private val rec = TerminalRecorder(width = 24)
+    private val t = Terminal(terminalInterface = rec)
+
+    @Test
+    @JsName("print_during_animation")
+    fun `print during animation`() {
+        val a = t.textAnimation<Int> { "<$it>\n===" }
+        a.update(1)
+        rec.output() shouldBe "<1>\n===\n"
+
+        // update
+        rec.clearOutput()
+        a.update(2)
+        var moves = t.cursor.getMoves { startOfLine(); up(2) }
+        rec.output() shouldBe "${moves}<2>\n===\n"
+
+        // print while active
+        rec.clearOutput()
+        t.println("X")
+        moves = t.cursor.getMoves { startOfLine(); up(2); clearScreenAfterCursor() }
+        rec.output() shouldBe "${moves}X\n<2>\n===\n"
+
+        // clear
+        rec.clearOutput()
+        a.clear()
+        moves = t.cursor.getMoves { startOfLine(); up(2); clearScreenAfterCursor() }
+        rec.output() shouldBe moves
+
+        // repeat clear
+        rec.clearOutput()
+        a.clear()
+        rec.output() shouldBe ""
+
+        // update after clear
+        rec.clearOutput()
+        a.update(3)
+        rec.output() shouldBe "<3>\n===\n"
+
+        // stop
+        rec.clearOutput()
+        a.stop()
+        rec.output() shouldBe ""
+
+        // print after stop
+        rec.clearOutput()
+        t.println("X")
+        rec.output() shouldBe "X\n"
+
+        // update after stop
+        rec.clearOutput()
+        a.update(4)
+        rec.output() shouldBe "<4>\n===\n"
+    }
+}