diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cea6a6656..0f8dc5de4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Test and Publish +name: Build on: pull_request: @@ -37,6 +37,7 @@ jobs: with: arguments: | :mordant:check + :extensions:mordant-coroutines:jvmTest :test:proguard:r8jar ${{matrix.EXTRA_GRADLE_ARGS}} --stacktrace diff --git a/CHANGELOG.md b/CHANGELOG.md index 4512781ed..9289a3598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,31 @@ # Changelog +## Unreleased +### Added +- New implementation of progress bars with a number of improvements: + - Any widget can be added to a progress layout, not just the built-in cell types + - TODO New cell: timeElapsed + - Added `compact` style to `timeRemaining` cells. + - Added `marquee` cell that can scroll text that is larger than a fixed width. +- Added `Viewport` widget that can crop or pad another widget to a fixed size, and scroll it within that size. +- Added `precision` parameter to `completed` progress cell that controls the number of decimal places shown. +- Animation now handle terminal resizing, although on some terminals, partially drawn frames may be visible. Due to a bug in JNI, the terminal size isn't automatically updated on JVM on macOS. +- Added `TableBuilder.addPaddingWidthToFixedWidth` option to control how padding is added to fixed width columns. + +### Changed +- Animations now never add a trailing newline while they're running. They always add one once the animation is stopped. The `trailNewline` parameter is deprecated. This allows full screen animations without a blank line at the bottom. + +### Fixed +- Vertical layout now correctly pads non-text cells when `align` is set to `TextAlign.LEFT` + ## 2.3.0 ### Added - Vararg constructors for `UnorderedList` and `OrderedList` -- `UnorderedList` and `OrderedList` now support being empty +- `UnorderedList` and `OrderedList` now support being empty - Added optional terminal frame to `TerminalRecorder.outputAsHtml` ### Changed -- When setting conflicting styles on a `Table` or its cells, the innermost style now takes precedence (i.e. if you set different styles on the whole table and a cell, the style applied to the cell will be used). +- When setting conflicting styles on a `Table` or its cells, the innermost style now takes precedence (i.e. if you set different styles on the whole table and a cell, the style applied to the cell will be used). ### Fixed - Updated bundled proguard rules [(#130)](https://github.com/ajalt/mordant/issues/130) diff --git a/build.gradle.kts b/build.gradle.kts index 3f7c1afb8..dd6079128 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,9 @@ +import org.jetbrains.dokka.gradle.DokkaMultiModuleTask +import org.jetbrains.dokka.gradle.DokkaTask + plugins { alias(libs.plugins.kotlinBinaryCompatibilityValidator) + id("org.jetbrains.dokka") } apiValidation { @@ -7,3 +11,14 @@ apiValidation { project("samples").subprojects.mapTo(ignoredProjects) { it.name } project("test").subprojects.mapTo(ignoredProjects) { it.name } } + +tasks.withType().configureEach { + outputDirectory.set(rootProject.rootDir.resolve("docs/api")) + pluginsMapConfiguration.set( + mapOf( + "org.jetbrains.dokka.base.DokkaBase" to """{ + "footerMessage": "Copyright © 2017 AJ Alt" + }""" + ) + ) +} diff --git a/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts index 4543d5949..983503d7b 100644 --- a/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts @@ -8,10 +8,8 @@ kotlin { jvm { withJava() } sourceSets { - val jvmMain by getting { - dependencies { - implementation(project(":mordant")) - } + jvmMain.dependencies { + implementation(project(":mordant")) } } } diff --git a/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts index 8f3c914d1..182af3ae3 100644 --- a/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts @@ -2,3 +2,11 @@ plugins { id("mordant-jvm-sample-conventions") id("mordant-native-sample-conventions") } + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":mordant")) + } + } +} diff --git a/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts index 06db7e1ad..f40927678 100644 --- a/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts @@ -4,6 +4,7 @@ import com.vanniktech.maven.publish.JavadocJar import com.vanniktech.maven.publish.KotlinMultiplatform import com.vanniktech.maven.publish.SonatypeHost import org.jetbrains.dokka.gradle.DokkaTask +import org.jetbrains.dokka.gradle.DokkaTaskPartial import java.io.ByteArrayOutputStream plugins { @@ -35,22 +36,13 @@ fun getPublishVersion(): String { mavenPublishing { project.setProperty("VERSION_NAME", getPublishVersion()) pomFromGradleProperties() - configure(KotlinMultiplatform(JavadocJar.Dokka("dokkaHtml"))) + configure(KotlinMultiplatform(JavadocJar.Dokka("dokkaHtmlPartial"))) publishToMavenCentral(SonatypeHost.DEFAULT) signAllPublications() } -tasks.named("dokkaHtml") { - outputDirectory.set(rootProject.rootDir.resolve("docs/api")) - pluginsMapConfiguration.set( - mapOf( - "org.jetbrains.dokka.base.DokkaBase" to """{ - "footerMessage": "Copyright © 2017 AJ Alt" - }""" - ) - ) +tasks.withType().configureEach { dokkaSourceSets.configureEach { - reportUndocumented.set(false) skipDeprecated.set(true) } } diff --git a/deploy_website.sh b/deploy_website.sh index b4504e041..41a098895 100755 --- a/deploy_website.sh +++ b/deploy_website.sh @@ -9,7 +9,7 @@ set -ex # Generate API docs -./gradlew dokkaHtml +./gradlew dokkaHtmlMultiModule # Copy the changelog into the site, omitting the unreleased section cat CHANGELOG.md \ diff --git a/docs/guide.md b/docs/guide.md index 54aa12bb4..55d0d091b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -370,19 +370,20 @@ t.cursor.hide(showOnExit = true) ## Animations You can animate any widget like a table with [Terminal.animation], or any regular -string with [Terminal.textAnimation]. +string with [Terminal.textAnimation]. For progress bar animations, see [the docs on progress +bars](progress.md). === "Code" ```kotlin - val t = Terminal() - val a = t.textAnimation { frame -> + val terminal = Terminal() + val a = terminal.textAnimation { frame -> (1..50).joinToString("") { val hue = (frame + it) * 3 % 360 TextColors.hsv(hue, 1, 1)("━") } } - t.cursor.hide(showOnExit = true) + terminal.cursor.hide(showOnExit = true) repeat(120) { a.update(it) Thread.sleep(25) @@ -390,41 +391,13 @@ string with [Terminal.textAnimation]. ``` === "Output" - ![](img/animation.svg) - - -## Progress bars - -You can create customizable progress bars that automatically compute speed and time remaining. - -=== "Code" - ```kotlin - val t = Terminal() - val progress = t.progressAnimation { - text("my-file.iso") - percentage() - progressBar() - completed() - speed("B/s") - timeRemaining() - } - ``` -=== "Output" -
-
● ● ● 
-
-    my-file.iso 83% ━━━━━━━━━━ ━━━ 25.0/30.0G 71.2MB/s  eta 0:01:10
-    
-
-Call [progress.start] to animate the progress, and [progress.update] or [progress.advance] as your -task completes. + ![](img/animation_text.gif) -!!! note - The [progressAnimation] builder is currently JVM-only - see [issue #148](https://github.com/ajalt/mordant/issues/148). - On other platforms, you can still use `t.animation { progressLayout { ... } }` which will render the same widget, - you'll just need to call [progress.build] manually. +!!! tip + If you have an `animation` or `textAnimation`, you can refresh them automatically with + [animateOnThread] or [animateOnCoroutine]. ## Prompting for input @@ -464,6 +437,8 @@ creating a subclass of the [Prompt] class. Mordant includes [StringPrompt], [Yes [Text]: api/mordant/com.github.ajalt.mordant.widgets/-text/index.html [Whitespace]: api/mordant/com.github.ajalt.mordant.rendering/-whitespace/index.html [YesNoPrompt]: api/mordant/com.github.ajalt.mordant.terminal/-yes-no-prompt/index.html +[animateOnCoroutine]: api/extensions/mordant-coroutines/com.github.ajalt.mordant.animation.coroutines/animate-in-coroutine.html +[animateOnThread]: api/mordant/com.github.ajalt.mordant.animation.progress/animate-on-thread.html [color.bg]: api/mordant/com.github.ajalt.mordant.rendering/-text-style/bg.html [color.on]: api/mordant/com.github.ajalt.mordant.rendering/-text-style/on.html [cursor]: api/mordant/com.github.ajalt.mordant.terminal/-terminal/cursor.html diff --git a/docs/img/animation_text.gif b/docs/img/animation_text.gif new file mode 100644 index 000000000..cbc1d2b08 Binary files /dev/null and b/docs/img/animation_text.gif differ diff --git a/docs/img/complex_table.png b/docs/img/complex_table.png deleted file mode 100644 index 20abdfdab..000000000 Binary files a/docs/img/complex_table.png and /dev/null differ diff --git a/docs/img/example_basic.png b/docs/img/example_basic.png deleted file mode 100644 index 807c897ef..000000000 Binary files a/docs/img/example_basic.png and /dev/null differ diff --git a/docs/img/example_bg.png b/docs/img/example_bg.png deleted file mode 100644 index 3b44c94d0..000000000 Binary files a/docs/img/example_bg.png and /dev/null differ diff --git a/docs/img/example_fg_bg.png b/docs/img/example_fg_bg.png deleted file mode 100644 index dd2637da5..000000000 Binary files a/docs/img/example_fg_bg.png and /dev/null differ diff --git a/docs/img/example_multi.png b/docs/img/example_multi.png deleted file mode 100644 index befd77898..000000000 Binary files a/docs/img/example_multi.png and /dev/null differ diff --git a/docs/img/example_nesting.png b/docs/img/example_nesting.png deleted file mode 100644 index 28dbc027f..000000000 Binary files a/docs/img/example_nesting.png and /dev/null differ diff --git a/docs/img/example_progress.png b/docs/img/example_progress.png deleted file mode 100644 index 107154662..000000000 Binary files a/docs/img/example_progress.png and /dev/null differ diff --git a/docs/img/example_rgb.png b/docs/img/example_rgb.png deleted file mode 100644 index 3bbef7fd7..000000000 Binary files a/docs/img/example_rgb.png and /dev/null differ diff --git a/docs/img/example_styles.png b/docs/img/example_styles.png deleted file mode 100644 index 0bdaf5a19..000000000 Binary files a/docs/img/example_styles.png and /dev/null differ diff --git a/docs/img/markdown.png b/docs/img/markdown.png deleted file mode 100644 index 49e1bd072..000000000 Binary files a/docs/img/markdown.png and /dev/null differ diff --git a/docs/img/progess_simple.gif b/docs/img/progess_simple.gif new file mode 100644 index 000000000..3f18ac4f8 Binary files /dev/null and b/docs/img/progess_simple.gif differ diff --git a/docs/img/progress_cells.gif b/docs/img/progress_cells.gif new file mode 100644 index 000000000..4af3d350e Binary files /dev/null and b/docs/img/progress_cells.gif differ diff --git a/docs/img/progress_context.gif b/docs/img/progress_context.gif new file mode 100644 index 000000000..5bb874f5d Binary files /dev/null and b/docs/img/progress_context.gif differ diff --git a/docs/img/progress_multi.gif b/docs/img/progress_multi.gif new file mode 100644 index 000000000..01172387e Binary files /dev/null and b/docs/img/progress_multi.gif differ diff --git a/docs/img/simple_table.png b/docs/img/simple_table.png deleted file mode 100644 index 3dc3e9fbf..000000000 Binary files a/docs/img/simple_table.png and /dev/null differ diff --git a/docs/progress.md b/docs/progress.md new file mode 100644 index 000000000..7628fc6c1 --- /dev/null +++ b/docs/progress.md @@ -0,0 +1,269 @@ +# Progress Bars + +Mordant provides a simple way to create animated progress bars in your terminal. + +# Basic Usage + +You can use the [progressBarLayout] DSL to define the layout of your progress bar. Then you +can start the animation either on a thread with [animateOnThread], or +using coroutines with [animateOnCoroutine].`animateOnThread` is JVM-only, but `animateOnCoroutine` +is available on all platforms using the `mordant-coroutines` module. + +Once the animation is started, you can update the progress bar by calling [update] and [advance]. + +=== "Example with Coroutines" + + ```kotlin + val progress = progressBarLayout { + marquee(terminal.theme.warning("my-file-download.bin"), width = 15) + percentage() + progressBar() + completed(style = terminal.theme.success) + speed("B/s", style = terminal.theme.info) + timeRemaining(style = magenta) + }.animateInCoroutine(terminal) + + launch { progress.execute() } + + // Update the progress as the download progresses + progress.update { total = 3_000_000_000 } + while (!progress.finished) { + progress.advance(15_000_000) + Thread.sleep(100) + } + ``` + +=== "Example with Threads" + + ```kotlin + val progress = progressBarLayout { + marquee(terminal.theme.warning("my-file-download.bin"), width = 15) + percentage() + progressBar() + completed(style = terminal.theme.success) + speed("B/s", style = terminal.theme.info) + timeRemaining(style = magenta) + }.animateOnThread(terminal) + + val future = progress.execute() + + // Update the progress as the download progresses + progress.update { total = 3_000_000_000 } + while (!progress.finished) { + progress.advance(15_000_000) + Thread.sleep(100) + } + + // Optional: wait for the future to complete so that the final frame of the + // animation is rendered before the program exits. + future.get() + ``` + +=== "Output" + + ![](img/progess_simple.gif) + +# Changing Text While Animation is Running + +You can pass data to the progress bar by using [progressBarContextLayout], which allows you to +set a [context][ProgressTaskUpdateScope.context] value that your progress bar can use to render +dynamic text. + +=== "Example with Context" + + ```kotlin + val progress = progressBarContextLayout { + text { "Status: $context" } + progressBar() + completed() + }.animateInCoroutine(terminal, context = "Starting", total = 4, completed = 1) + + launch { progress.execute() } + + val states = listOf("Downloading", "Extracting", "Done") + for (state in states) { + delay(2.seconds) + progress.update { + context = state + completed += 1 + } + } + ``` + +=== "Output" + + ![](img/progress_context.gif) + +!!! tip + + If you want a builder instead of a DSL, you can use the [ProgressLayoutBuilder] + +# Multiple Progress Bars + +You can create multiple progress bars running at the same time using [MultiProgressBarAnimation]. +Call [addTask] for each progress bar you want, passing in the layout for that bar. You can +use the same layout for multiple tasks, or different layouts for some of them. + +You can call [advance] and [update] on each task to update them separately. + +The columns of the progress bars will have their widths aligned to the same size by default, +but you can change this by setting the `alignColumns` parameter in the layout. + +=== "Example with Multiple Progress Bars" + + ```kotlin + val overallLayout = progressBarLayout(alignColumns = false) { + progressBar(width = 20) + percentage() + timeElapsed(compact = false) + } + val taskLayout = progressBarContextLayout { + text(fps = animationFps, align = TextAlign.LEFT) { "〉 step $context" } + } + + val progress = MultiProgressBarAnimation(terminal).animateInCoroutine() + val overall = progress.addTask(overallLayout, total = 100) + val tasks = List(3) { progress.addTask(taskLayout, total = 1, completed = 1, context = 0) } + + launch { progress.execute() } + + for (i in 1..100) { + overall.advance() + tasks[i % 3].update { context = i } + delay(100) + } + ``` + +=== "Output" + + ![](img/progress_multi.gif) + +!!! tip + + The progress animation will keep running until all tasks are [finished]. If you want to stop sooner, + you can set all the tasks' `completed` equal to their `total`, or cancel the coroutine scope or + future that the animation is running in. + +# Available Progress Bar Cell Types + +Mordant provides several cell types that you can use to build your progress bar layouts, or you can +make your own with [cell] or [text]. + +=== "Output" + + ![](img/progress_cells.gif) + +=== "Code" + + ```kotlin + // Use a custom maker to build render the cells in a vertical definitionList + object VerticalProgressBarMaker : ProgressBarWidgetMaker { + override fun build(rows: List>): Widget { + return definitionList { + inline = true + val widgets = MultiProgressBarWidgetMaker.buildCells(rows) + for ((term, desc) in widgets.flatten().windowed(2, 2)) { + entry(term, desc) + } + } + } + } + + val progress = progressBarLayout { + text("text"); text("text") + text("marquee"); marquee("marquee", width = 10, scrollWhenContentFits = true) + text("completed"); completed() + text("speed"); speed() + text("percentage"); percentage() + text("timeRemaining"); timeRemaining() + text("timeElapsed"); timeElapsed() + text("spinner"); spinner(Spinner.Lines()) + text("progressBar"); progressBar() + }.animateOnThread(terminal, maker = VerticalProgressBarMaker) + + launch { progress.execute() } + + while (!progress.finished) { + progress.advance() + delay(100) + } + ``` + +| Cell Type | Description | +|-----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [text] | You can make a static text cell with `text("")`, or a dynamic one with `text {""}` | +| [marquee] | A fixed-width text cell that scrolls its contents when they're larger than the cell. You can make the content always scroll by setting `scrollWhenContentFits=true` | +| [completed] | A cell that shows the [completed] count and optionally the [total]. It uses SI units for amounts larger than 1000 | +| [speed] | A cell that shows the speed of the progress, in bytes per second. | +| [percentage] | A cell that shows the completed percentage. | +| [timeRemaining] | A cell that shows the estimated time remaining, or optionally the elapsed time once a task finishes. If you want a different time format, you can do `text { myFormat(calculateTimeRemaining()) }` | +| [timeElapsed] | A cell that shows the elapsed time. If you want a different time format, you can do `text { myFormat(calculateTimeElapsed()) }` | +| [spinner] | A cell that shows an animated [Spinner]. | +| [progressBar] | A cell that shows a progress bar. | +| [cell] | A custom cell that can show any Widget | + +# Animating on Custom Threads + +If you want to run an animation on your own threading infrastructure instead of a Java Executor, there +are a couple of ways to do it. + +## With `runBlocking` + +If you are on JVM, you can still use [animateOnThread], but call [BlockingAnimator.runBlocking] on +you own thread instead of using [execute]. + +For example, to run an animation with RxJava: + +```kotlin +val progress = progressBarLayout { /* ... */ }.animateOnThread(terminal) +Completable.create { progress.runBlocking() } + .subscribeOn(Schedulers.computation()) + .subscribe() +``` + +## Calling `refresh` manually + +If you aren't on JVM or want even more control, you can create a [MultiProgressBarAnimation] and +call [refresh] manually each time you want a new frame to be rendered. + +```kotlin +val layout = progressBarLayout { /* ... */ } +val animation = MultiProgressBarAnimation(terminal) +val task = animation.addTask(layout, total = 100) + +while (!animation.finished) { + task.advance() + animation.refresh() + sleep(33) +} + +// Refresh all cells to draw the final frame +animation.refresh(refreshAll = true) +``` + + +[BlockingAnimator.runBlocking]: api/mordant/com.github.ajalt.mordant.animation.progress/-blocking-animator/run-blocking.html +[MultiProgressBarAnimation]: api/mordant/com.github.ajalt.mordant.animation.progress/-multi-progress-bar-animation/index.html +[ProgressLayoutBuilder]: api/mordant/com.github.ajalt.mordant.widgets.progress/-progress-layout-builder/index.html +[ProgressTaskUpdateScope.context]: api/mordant/com.github.ajalt.mordant.animation.progress/-progress-task-update-scope/context.html +[Spinner]: api/mordant/com.github.ajalt.mordant.widgets/-spinner/index.html +[addTask]: api/mordant/com.github.ajalt.mordant.animation.progress/-progress-bar-animation/add-task.html +[advance]: api/mordant/com.github.ajalt.mordant.animation.progress/advance.html +[animateOnThread]: api/mordant/com.github.ajalt.mordant.animation.progress/animate-on-thread.html +[cell]: api/mordant/com.github.ajalt.mordant.widgets.progress/-progress-layout-scope/cell.html +[completed]: api/mordant/com.github.ajalt.mordant.animation.progress/-progress-task/completed.html +[execute]: api/mordant/com.github.ajalt.mordant.animation.progress/execute.html +[finished]: api/mordant/com.github.ajalt.mordant.animation.progress/-progress-task/finished.html +[marquee]: api/mordant/com.github.ajalt.mordant.widgets.progress/marquee.html +[percentage]: api/mordant/com.github.ajalt.mordant.widgets.progress/percentage.html +[progressBarContextLayout]: api/mordant/com.github.ajalt.mordant.widgets.progress/progress-bar-context-layout.html +[progressBarLayout]: api/mordant/com.github.ajalt.mordant.widgets.progress/progress-bar-layout.html +[progressBar]: api/mordant/com.github.ajalt.mordant.widgets.progress/progress-bar.html +[refresh]: api/mordant/com.github.ajalt.mordant.animation.progress/-progress-bar-animation/refresh.html +[speed]: api/mordant/com.github.ajalt.mordant.widgets.progress/speed.html +[spinner]: api/mordant/com.github.ajalt.mordant.widgets.progress/spinner.html +[text]: api/mordant/com.github.ajalt.mordant.widgets.progress/text.html +[timeElapsed]: api/mordant/com.github.ajalt.mordant.widgets.progress/time-elapsed.html +[timeRemaining]: api/mordant/com.github.ajalt.mordant.widgets.progress/time-remaining.html +[total]: api/mordant/com.github.ajalt.mordant.animation.progress/-progress-task/total.html +[update]: api/mordant/com.github.ajalt.mordant.animation.progress/update.html diff --git a/extensions/mordant-coroutines/api/mordant-coroutines.api b/extensions/mordant-coroutines/api/mordant-coroutines.api new file mode 100644 index 000000000..c856e66a4 --- /dev/null +++ b/extensions/mordant-coroutines/api/mordant-coroutines.api @@ -0,0 +1,42 @@ +public final class com/github/ajalt/mordant/animation/coroutines/BaseCoroutineAnimator : com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator { + public fun (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/animation/RefreshableAnimation;)V + public fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator { + public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/github/ajalt/mordant/animation/coroutines/CoroutineAnimatorKt { + public static final fun animateInCoroutine (Lcom/github/ajalt/mordant/animation/Animation;ILkotlin/jvm/functions/Function0;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineAnimator; + public static final fun animateInCoroutine (Lcom/github/ajalt/mordant/animation/RefreshableAnimation;Lcom/github/ajalt/mordant/terminal/Terminal;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineAnimator; + public static final fun animateInCoroutine (Lcom/github/ajalt/mordant/animation/progress/MultiProgressBarAnimation;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineProgressAnimator; + public static synthetic fun animateInCoroutine$default (Lcom/github/ajalt/mordant/animation/Animation;ILkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineAnimator; + public static final fun animateInCoroutine-TNmY5B4 (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineProgressTaskAnimator; + public static synthetic fun animateInCoroutine-TNmY5B4$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineProgressTaskAnimator; + public static final fun animateInCoroutine-u4uj9nY (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineProgressTaskAnimator; + public static synthetic fun animateInCoroutine-u4uj9nY$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/coroutines/CoroutineProgressTaskAnimator; +} + +public abstract interface class com/github/ajalt/mordant/animation/coroutines/CoroutineProgressAnimator : com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator, com/github/ajalt/mordant/animation/progress/ProgressBarAnimation { +} + +public final class com/github/ajalt/mordant/animation/coroutines/CoroutineProgressBarAnimation : com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator, com/github/ajalt/mordant/animation/progress/ProgressBarAnimation { + public synthetic fun (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ZJLkotlin/time/TimeSource$WithComparableMarks;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ZJLkotlin/time/TimeSource$WithComparableMarks;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addTask (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JZZ)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; + public fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getFinished ()Z + public fun refresh (Z)V + public fun removeTask (Lcom/github/ajalt/mordant/widgets/progress/TaskId;)Z + public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/github/ajalt/mordant/animation/coroutines/CoroutineProgressTaskAnimator : com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator, com/github/ajalt/mordant/animation/progress/ProgressTask { +} + diff --git a/extensions/mordant-coroutines/build.gradle.kts b/extensions/mordant-coroutines/build.gradle.kts new file mode 100644 index 000000000..add6b4fa1 --- /dev/null +++ b/extensions/mordant-coroutines/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("mordant-mpp-conventions") + id("mordant-publishing-conventions") +} + +kotlin { + sourceSets { + commonMain.dependencies { + api(project(":mordant")) + api(libs.coroutines.core) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotest) + implementation(libs.coroutines.test) + } + } +} diff --git a/extensions/mordant-coroutines/gradle.properties b/extensions/mordant-coroutines/gradle.properties new file mode 100644 index 000000000..0d2de7ac8 --- /dev/null +++ b/extensions/mordant-coroutines/gradle.properties @@ -0,0 +1,2 @@ +POM_ARTIFACT_ID=mordant-coroutines +POM_NAME=Mordant Coroutines Extensions diff --git a/extensions/mordant-coroutines/src/commonMain/kotlin/com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator.kt b/extensions/mordant-coroutines/src/commonMain/kotlin/com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator.kt new file mode 100644 index 000000000..e5620979f --- /dev/null +++ b/extensions/mordant-coroutines/src/commonMain/kotlin/com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator.kt @@ -0,0 +1,262 @@ +package com.github.ajalt.mordant.animation.coroutines + +import com.github.ajalt.mordant.animation.Animation +import com.github.ajalt.mordant.animation.RefreshableAnimation +import com.github.ajalt.mordant.animation.asRefreshable +import com.github.ajalt.mordant.animation.progress.MultiProgressBarAnimation +import com.github.ajalt.mordant.animation.progress.ProgressBarAnimation +import com.github.ajalt.mordant.animation.progress.ProgressTask +import com.github.ajalt.mordant.animation.refreshPeriod +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.widgets.progress.MultiProgressBarWidgetMaker +import com.github.ajalt.mordant.widgets.progress.ProgressBarDefinition +import com.github.ajalt.mordant.widgets.progress.ProgressBarWidgetMaker +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +interface CoroutineAnimator { + /** + * Start the animation and refresh it until all its tasks are finished. + */ + suspend fun execute() + + /** + * Stop the animation, but leave it on the screen. + */ + suspend fun stop() + + /** + * Stop the animation and remove it from the screen. + */ + suspend fun clear() +} + + +/** + * A [CoroutineAnimator] for a single [task][ProgressTask]. + */ +interface CoroutineProgressTaskAnimator : CoroutineAnimator, ProgressTask + +/** + * A [CoroutineAnimator] for a [ProgressBarAnimation]. + */ +interface CoroutineProgressAnimator : CoroutineAnimator, ProgressBarAnimation + +class BaseCoroutineAnimator( + private val terminal: Terminal, + private val animation: RefreshableAnimation, +) : CoroutineAnimator { + private var stopped = false + private val mutex = Mutex() + + override suspend fun execute() { + mutex.withLock { + stopped = false + terminal.cursor.hide(showOnExit = true) + } + while (mutex.withLock { !stopped && !animation.finished }) { + mutex.withLock { animation.refresh(refreshAll = false) } + delay(animation.refreshPeriod.inWholeMilliseconds) + } + mutex.withLock { + // final refresh to show finished state + if (!stopped) animation.refresh(refreshAll = true) + } + } + + override suspend fun stop(): Unit = mutex.withLock { + if (stopped) return@withLock + animation.stop() + terminal.cursor.show() + stopped = true + } + + override suspend fun clear(): Unit = mutex.withLock { + if (stopped) return@withLock + animation.clear() + terminal.cursor.show() + stopped = true + } +} + +class CoroutineProgressBarAnimation private constructor( + private val animation: ProgressBarAnimation, + private val animator: CoroutineAnimator, +) : ProgressBarAnimation by animation, CoroutineAnimator by animator { + private constructor( + terminal: Terminal, + animation: MultiProgressBarAnimation, + ) : this(animation, BaseCoroutineAnimator(terminal, animation)) + + constructor( + terminal: Terminal, + maker: ProgressBarWidgetMaker, + clearWhenFinished: Boolean = false, + speedEstimateDuration: Duration = 30.seconds, + timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, + ) : this( + terminal, + MultiProgressBarAnimation( + terminal, + clearWhenFinished, + speedEstimateDuration, + maker, + timeSource + ), + ) +} + +/** + * Create a progress bar animation that runs in a coroutine. + * + * ### Example + * + * ``` + * val animation = progressBarContextLayout { ... }.animateInCoroutine(terminal, "context") + * launch { animation.execute() } + * animation.update { ... } + * ``` + * + * @param terminal The terminal to draw the progress bar to + * @param context The context to pass to the task + * @param total The total number of steps needed to complete the progress task, or `null` if it is indeterminate. + * @param completed The number of steps currently completed in the progress task. + * @param start If `true`, start the task immediately. + * @param visible If `false`, the task will not be drawn to the screen. + * @param clearWhenFinished If `true`, the animation will be cleared when all tasks are finished. Otherwise, the animation will stop when all tasks are finished, but remain on the screen. + * @param speedEstimateDuration The duration over which to estimate the speed of the progress tasks. This estimate will be a rolling average over this duration. + * @param timeSource The time source to use for the animation. + * @param maker The widget maker to use to lay out the progress bars. + */ +fun ProgressBarDefinition.animateInCoroutine( + terminal: Terminal, + context: T, + total: Long? = null, + completed: Long = 0, + start: Boolean = true, + visible: Boolean = true, + clearWhenFinished: Boolean = false, + speedEstimateDuration: Duration = 30.seconds, + timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, + maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker, +): CoroutineProgressTaskAnimator { + val animation = CoroutineProgressBarAnimation( + terminal, + maker, + clearWhenFinished, + speedEstimateDuration, + timeSource + ) + val task = animation.addTask(this, context, total, completed, start, visible) + return CoroutineProgressTaskAnimatorImpl(task, animation) +} + +/** + * Create a progress bar animation for a single task that runs synchronously. + * + * ### Example + * + * ``` + * val animation = progressBarLayout { ... }.animateInCoroutine(terminal) + * launch { animation.execute() } + * animation.update { ... } + * ``` + * + * @param terminal The terminal to draw the progress bar to + * @param total The total number of steps needed to complete the progress task, or `null` if it is indeterminate. + * @param completed The number of steps currently completed in the progress task. + * @param start If `true`, start the task immediately. + * @param visible If `false`, the task will not be drawn to the screen. + * @param clearWhenFinished If `true`, the animation will be cleared when all tasks are finished. Otherwise, the animation will stop when all tasks are finished, but remain on the screen. + * @param speedEstimateDuration The duration over which to estimate the speed of the progress tasks. This estimate will be a rolling average over this duration. + * @param timeSource The time source to use for the animation. + * @param maker The widget maker to use to lay out the progress bars. + */ +fun ProgressBarDefinition.animateInCoroutine( + terminal: Terminal, + total: Long? = null, + completed: Long = 0, + start: Boolean = true, + visible: Boolean = true, + clearWhenFinished: Boolean = false, + speedEstimateDuration: Duration = 30.seconds, + timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, + maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker, +): CoroutineProgressTaskAnimator { + return animateInCoroutine( + terminal = terminal, + context = Unit, + total = total, + completed = completed, + start = start, + visible = visible, + clearWhenFinished = clearWhenFinished, + speedEstimateDuration = speedEstimateDuration, + timeSource = timeSource, + maker = maker + ) +} + +/** + * Create an animator that runs this animation in a coroutine. + * + * ### Example + * ``` + * val animation = terminal.animation { ... }.animateInCoroutine(terminal) + * launch { animation.execute() } + * ``` + */ +inline fun Animation.animateInCoroutine( + fps: Int = 30, + crossinline finished: () -> Boolean = { false }, +): CoroutineAnimator { + return asRefreshable(fps, finished).animateInCoroutine(terminal) +} + +/** + * Create an animator that runs this animation in a coroutine. + * + * ### Example + * + * ``` + * val animator = animation.animateInCoroutine(terminal) + * launch { animator.execute() } + * ``` + */ +fun RefreshableAnimation.animateInCoroutine(terminal: Terminal): CoroutineAnimator { + return BaseCoroutineAnimator(terminal, this) +} + + +/** + * Create an animator that runs this animation in a coroutine. + * + * ### Example + * + * ``` + * val animator = animation.animateInCoroutine() + * launch { animator.execute() } + * ``` + */ +fun MultiProgressBarAnimation.animateInCoroutine(): CoroutineProgressAnimator { + return CoroutineProgressAnimatorImpl(this, animateInCoroutine(terminal)) +} + + +private class CoroutineProgressTaskAnimatorImpl( + private val task: ProgressTask, + private val animator: CoroutineAnimator, +) : CoroutineProgressTaskAnimator, + CoroutineAnimator by animator, + ProgressTask by task + +private class CoroutineProgressAnimatorImpl( + private val animation: ProgressBarAnimation, + private val animator: CoroutineAnimator, +) : CoroutineProgressAnimator, + ProgressBarAnimation by animation, + CoroutineAnimator by animator diff --git a/extensions/mordant-coroutines/src/commonTest/kotlin/com/github/ajalt/mordant/animation/coroutines/CoroutinesAnimatorTest.kt b/extensions/mordant-coroutines/src/commonTest/kotlin/com/github/ajalt/mordant/animation/coroutines/CoroutinesAnimatorTest.kt new file mode 100644 index 000000000..e1a736acb --- /dev/null +++ b/extensions/mordant-coroutines/src/commonTest/kotlin/com/github/ajalt/mordant/animation/coroutines/CoroutinesAnimatorTest.kt @@ -0,0 +1,131 @@ +package com.github.ajalt.mordant.animation.coroutines + +import com.github.ajalt.mordant.animation.progress.MultiProgressBarAnimation +import com.github.ajalt.mordant.animation.progress.addTask +import com.github.ajalt.mordant.animation.progress.advance +import com.github.ajalt.mordant.animation.progress.update +import com.github.ajalt.mordant.animation.textAnimation +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.terminal.TerminalRecorder +import com.github.ajalt.mordant.widgets.progress.completed +import com.github.ajalt.mordant.widgets.progress.progressBarLayout +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.testTimeSource +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime + +private const val ESC = "\u001B" +private const val CSI = "$ESC[" +private const val HIDE_CURSOR = "$CSI?25l" +private const val SHOW_CURSOR = "$CSI?25h" + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +class CoroutinesAnimatorTest { + private val vt = TerminalRecorder(width = 56) + private val t = Terminal(terminalInterface = vt) + + @Test + fun testFps() = runTest { + val a = progressBarLayout(spacing = 0, textFps = 1) { + completed(suffix = "a") + completed(suffix = "b", fps = 2) + }.animateInCoroutine(t, total = 10, timeSource = testTimeSource) + + val job = backgroundScope.launch { a.execute() } + + advanceTimeBy(0.1.seconds) + vt.normalizedOutput() shouldBe "$HIDE_CURSOR 0/10a 0/10b" + vt.clearOutput() + + a.update(5) + advanceTimeBy(0.1.seconds) + vt.output() shouldBe "" + + advanceTimeBy(0.4.seconds) + vt.normalizedOutput() shouldBe " 0/10a 5/10b" + + vt.clearOutput() + advanceTimeBy(0.5.seconds) + vt.normalizedOutput() shouldBe " 5/10a 5/10b" + + advanceTimeBy(10.seconds) + + job.isActive shouldBe true + a.update(10) + + advanceTimeBy(1.seconds) + job.isActive shouldBe false + } + + @Test + @JsName("stop_and_clear") + fun `stop and clear`() = runTest { + val a = progressBarLayout(spacing = 0, textFps = 1) { + completed() + }.animateInCoroutine(t, total = 10, timeSource = testTimeSource) + var job = backgroundScope.launch { a.execute() } + advanceTimeBy(0.1.seconds) + a.stop() + advanceTimeBy(1.0.seconds) + job.isActive shouldBe false + vt.output() shouldBe "$HIDE_CURSOR 0/10\n$SHOW_CURSOR" + + vt.clearOutput() + job = backgroundScope.launch { a.execute() } + advanceTimeBy(0.1.seconds) + a.clear() + advanceTimeBy(1.0.seconds) + job.isActive shouldBe false + vt.output() shouldBe "$HIDE_CURSOR 0/10\r${CSI}0J$SHOW_CURSOR" + } + + @Test + @JsName("unit_animation") + fun `unit animation`() = runTest { + var i = 1 + var fin = false + val a = t.textAnimation { "$i" }.animateInCoroutine(fps = 1) { fin } + val job = backgroundScope.launch { a.execute() } + advanceTimeBy(0.1.seconds) + vt.normalizedOutput() shouldBe "$CSI?25l1" // hide cursor + vt.clearOutput() + + i = 2 + advanceTimeBy(0.1.seconds) + vt.output() shouldBe "" + + advanceTimeBy(1.0.seconds) + vt.normalizedOutput() shouldBe "2" + + job.isActive shouldBe true + fin = true + + advanceTimeBy(1.seconds) + job.isActive shouldBe false + } + + @Test + @JsName("multi_progress_animation") + fun `multi progress animation`() = runTest { + val layout = progressBarLayout { completed(fps = 1) } + val animation = MultiProgressBarAnimation(t).animateInCoroutine() + val task1 = animation.addTask(layout, total = 10) + val task2 = animation.addTask(layout, total = 10) + backgroundScope.launch { animation.execute() } + task1.advance(10) + task2.advance(10) + advanceTimeBy(1.1.seconds) + vt.output().shouldContain(" 10/10\n 10/10") + } + + private fun TerminalRecorder.normalizedOutput(): String { + return output().substringAfter("\r").trimEnd() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7230718f..fde4f6387 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] kotlin = "1.9.21" +coroutines = "1.8.0-RC" [libraries] colormath = "com.github.ajalt.colormath:colormath:3.3.1" @@ -9,15 +10,19 @@ jna-core = "net.java.dev.jna:jna:5.13.0" # compileOnly graalvm-svm = "org.graalvm.nativeimage:svm:23.1.0" +# used in extensions +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } + # used in tests kotest = "io.kotest:kotest-assertions-core:5.8.0" systemrules = "com.github.stefanbirkner:system-rules:1.19.0" r8 = "com.android.tools:r8:8.1.72" +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } # build logic kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } -dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version="1.9.10"} -publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version="0.25.3"} +dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "1.9.10" } +publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.25.3" } [plugins] graalvm-nativeimage = "org.graalvm.buildtools.native:0.9.28" diff --git a/mkdocs.yml b/mkdocs.yml index 72cc0a94a..43152e2a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,5 +55,6 @@ markdown_extensions: nav: - 'Getting Started': guide.md + - 'Progress Bars': progress.md - 'API Reference': api/index.html - 'Releases': changelog.md diff --git a/mordant/api/mordant.api b/mordant/api/mordant.api index d0a13d7af..7ef5557ef 100644 --- a/mordant/api/mordant.api +++ b/mordant/api/mordant.api @@ -2,6 +2,7 @@ public abstract class com/github/ajalt/mordant/animation/Animation { public fun (ZLcom/github/ajalt/mordant/terminal/Terminal;)V public synthetic fun (ZLcom/github/ajalt/mordant/terminal/Terminal;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun clear ()V + public final fun getTerminal ()Lcom/github/ajalt/mordant/terminal/Terminal; protected abstract fun renderData (Ljava/lang/Object;)Lcom/github/ajalt/mordant/rendering/Widget; public final fun stop ()V public final fun update (Ljava/lang/Object;)V @@ -41,6 +42,155 @@ public final class com/github/ajalt/mordant/animation/ProgressAnimationKt { public static final fun progressAnimation (Lcom/github/ajalt/mordant/terminal/Terminal;Lkotlin/jvm/functions/Function1;)Lcom/github/ajalt/mordant/animation/ProgressAnimation; } +public abstract interface class com/github/ajalt/mordant/animation/Refreshable { + public abstract fun getFinished ()Z + public abstract fun refresh (Z)V +} + +public final class com/github/ajalt/mordant/animation/Refreshable$DefaultImpls { + public static synthetic fun refresh$default (Lcom/github/ajalt/mordant/animation/Refreshable;ZILjava/lang/Object;)V +} + +public abstract interface class com/github/ajalt/mordant/animation/RefreshableAnimation : com/github/ajalt/mordant/animation/Refreshable { + public abstract fun clear ()V + public abstract fun getFps ()I + public abstract fun stop ()V +} + +public final class com/github/ajalt/mordant/animation/RefreshableAnimation$DefaultImpls { + public static fun getFps (Lcom/github/ajalt/mordant/animation/RefreshableAnimation;)I +} + +public final class com/github/ajalt/mordant/animation/RefreshableAnimationKt { + public static final fun asRefreshable (Lcom/github/ajalt/mordant/animation/Animation;ILkotlin/jvm/functions/Function0;)Lcom/github/ajalt/mordant/animation/RefreshableAnimation; + public static synthetic fun asRefreshable$default (Lcom/github/ajalt/mordant/animation/Animation;ILkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/RefreshableAnimation; + public static final fun getRefreshPeriod (Lcom/github/ajalt/mordant/animation/RefreshableAnimation;)J +} + +public final class com/github/ajalt/mordant/animation/progress/BaseBlockingAnimator : com/github/ajalt/mordant/animation/progress/BlockingAnimator { + public fun (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/animation/RefreshableAnimation;)V + public fun clear ()V + public fun getFinished ()Z + public fun getFps ()I + public fun refresh (Z)V + public fun runBlocking ()V + public fun stop ()V +} + +public abstract interface class com/github/ajalt/mordant/animation/progress/BlockingAnimator : com/github/ajalt/mordant/animation/RefreshableAnimation { + public abstract fun runBlocking ()V +} + +public final class com/github/ajalt/mordant/animation/progress/BlockingAnimator$DefaultImpls { + public static fun getFps (Lcom/github/ajalt/mordant/animation/progress/BlockingAnimator;)I +} + +public final class com/github/ajalt/mordant/animation/progress/BlockingProgressBarAnimation : com/github/ajalt/mordant/animation/progress/BlockingAnimator, com/github/ajalt/mordant/animation/progress/ProgressBarAnimation { + public synthetic fun (Lcom/github/ajalt/mordant/terminal/Terminal;ZJLcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;Lkotlin/time/TimeSource$WithComparableMarks;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/github/ajalt/mordant/terminal/Terminal;ZJLcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;Lkotlin/time/TimeSource$WithComparableMarks;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addTask (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JZZ)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; + public fun clear ()V + public fun getFinished ()Z + public fun getFps ()I + public fun refresh (Z)V + public fun removeTask (Lcom/github/ajalt/mordant/widgets/progress/TaskId;)Z + public fun runBlocking ()V + public fun stop ()V +} + +public final class com/github/ajalt/mordant/animation/progress/MultiProgressBarAnimation : com/github/ajalt/mordant/animation/RefreshableAnimation, com/github/ajalt/mordant/animation/progress/ProgressBarAnimation { + public synthetic fun (Lcom/github/ajalt/mordant/terminal/Terminal;ZJLcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;Lkotlin/time/TimeSource$WithComparableMarks;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/github/ajalt/mordant/terminal/Terminal;ZJLcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;Lkotlin/time/TimeSource$WithComparableMarks;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun addTask (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JZZ)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; + public fun clear ()V + public fun getFinished ()Z + public fun getFps ()I + public final fun getTerminal ()Lcom/github/ajalt/mordant/terminal/Terminal; + public fun refresh (Z)V + public fun removeTask (Lcom/github/ajalt/mordant/widgets/progress/TaskId;)Z + public fun stop ()V +} + +public abstract interface class com/github/ajalt/mordant/animation/progress/ProgressBarAnimation : com/github/ajalt/mordant/animation/Refreshable { + public abstract fun addTask (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JZZ)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; + public abstract fun removeTask (Lcom/github/ajalt/mordant/widgets/progress/TaskId;)Z +} + +public final class com/github/ajalt/mordant/animation/progress/ProgressBarAnimation$DefaultImpls { + public static synthetic fun addTask$default (Lcom/github/ajalt/mordant/animation/progress/ProgressBarAnimation;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JZZILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; +} + +public final class com/github/ajalt/mordant/animation/progress/ProgressBarAnimationKt { + public static final fun addTask (Lcom/github/ajalt/mordant/animation/progress/ProgressBarAnimation;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Long;JZZ)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; + public static synthetic fun addTask$default (Lcom/github/ajalt/mordant/animation/progress/ProgressBarAnimation;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Long;JZZILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/progress/ProgressTask; + public static final fun advance (Lcom/github/ajalt/mordant/animation/progress/ProgressTask;J)V + public static final fun advance (Lcom/github/ajalt/mordant/animation/progress/ProgressTask;Ljava/lang/Number;)V + public static synthetic fun advance$default (Lcom/github/ajalt/mordant/animation/progress/ProgressTask;JILjava/lang/Object;)V + public static final fun removeTask (Lcom/github/ajalt/mordant/animation/progress/ProgressBarAnimation;Lcom/github/ajalt/mordant/animation/progress/ProgressTask;)Z + public static final fun update (Lcom/github/ajalt/mordant/animation/progress/ProgressTask;J)V + public static final fun update (Lcom/github/ajalt/mordant/animation/progress/ProgressTask;Ljava/lang/Number;)V +} + +public abstract interface class com/github/ajalt/mordant/animation/progress/ProgressTask { + public abstract fun getCompleted ()J + public abstract fun getContext ()Ljava/lang/Object; + public abstract fun getFinished ()Z + public abstract fun getId ()Lcom/github/ajalt/mordant/widgets/progress/TaskId; + public abstract fun getPaused ()Z + public abstract fun getStarted ()Z + public abstract fun getTotal ()Ljava/lang/Long; + public abstract fun getVisible ()Z + public abstract fun makeState ()Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public abstract fun reset (ZLkotlin/jvm/functions/Function1;)V + public abstract fun update (Lkotlin/jvm/functions/Function1;)V +} + +public final class com/github/ajalt/mordant/animation/progress/ProgressTask$DefaultImpls { + public static synthetic fun reset$default (Lcom/github/ajalt/mordant/animation/progress/ProgressTask;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + +public abstract interface class com/github/ajalt/mordant/animation/progress/ProgressTaskUpdateScope { + public abstract fun getCompleted ()J + public abstract fun getContext ()Ljava/lang/Object; + public abstract fun getPaused ()Z + public abstract fun getStarted ()Z + public abstract fun getTotal ()Ljava/lang/Long; + public abstract fun getVisible ()Z + public abstract fun setCompleted (J)V + public abstract fun setContext (Ljava/lang/Object;)V + public abstract fun setPaused (Z)V + public abstract fun setStarted (Z)V + public abstract fun setTotal (Ljava/lang/Long;)V + public abstract fun setVisible (Z)V +} + +public final class com/github/ajalt/mordant/animation/progress/ThreadAnimatorKt { + public static final fun animateOnThread (Lcom/github/ajalt/mordant/animation/Animation;ILkotlin/jvm/functions/Function0;)Lcom/github/ajalt/mordant/animation/progress/BlockingAnimator; + public static final fun animateOnThread (Lcom/github/ajalt/mordant/animation/RefreshableAnimation;Lcom/github/ajalt/mordant/terminal/Terminal;)Lcom/github/ajalt/mordant/animation/progress/BlockingAnimator; + public static final fun animateOnThread (Lcom/github/ajalt/mordant/animation/progress/MultiProgressBarAnimation;)Lcom/github/ajalt/mordant/animation/progress/ThreadProgressAnimator; + public static synthetic fun animateOnThread$default (Lcom/github/ajalt/mordant/animation/Animation;ILkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/progress/BlockingAnimator; + public static final fun animateOnThread-TNmY5B4 (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator; + public static synthetic fun animateOnThread-TNmY5B4$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator; + public static final fun animateOnThread-u4uj9nY (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator; + public static synthetic fun animateOnThread-u4uj9nY$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Long;JZZZJLkotlin/time/TimeSource$WithComparableMarks;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator; + public static final fun execute (Lcom/github/ajalt/mordant/animation/progress/BlockingAnimator;Ljava/util/concurrent/ExecutorService;)Ljava/util/concurrent/Future; + public static synthetic fun execute$default (Lcom/github/ajalt/mordant/animation/progress/BlockingAnimator;Ljava/util/concurrent/ExecutorService;ILjava/lang/Object;)Ljava/util/concurrent/Future; +} + +public abstract interface class com/github/ajalt/mordant/animation/progress/ThreadProgressAnimator : com/github/ajalt/mordant/animation/progress/BlockingAnimator, com/github/ajalt/mordant/animation/progress/ProgressBarAnimation { +} + +public final class com/github/ajalt/mordant/animation/progress/ThreadProgressAnimator$DefaultImpls { + public static fun getFps (Lcom/github/ajalt/mordant/animation/progress/ThreadProgressAnimator;)I +} + +public abstract interface class com/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator : com/github/ajalt/mordant/animation/progress/BlockingAnimator, com/github/ajalt/mordant/animation/progress/ProgressTask { +} + +public final class com/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator$DefaultImpls { + public static fun getFps (Lcom/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator;)I +} + public final class com/github/ajalt/mordant/markdown/Markdown : com/github/ajalt/mordant/rendering/Widget { public fun (Ljava/lang/String;ZLjava/lang/Boolean;)V public synthetic fun (Ljava/lang/String;ZLjava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -516,6 +666,12 @@ public final class com/github/ajalt/mordant/table/ColumnBuilder$DefaultImpls { public static fun style (Lcom/github/ajalt/mordant/table/ColumnBuilder;Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/Color;ZZZZZZLjava/lang/String;)V } +public abstract interface class com/github/ajalt/mordant/table/ColumnHolderBuilder { + public abstract fun column (ILkotlin/jvm/functions/Function1;)V + public abstract fun getAddPaddingWidthToFixedWidth ()Z + public abstract fun setAddPaddingWidthToFixedWidth (Z)V +} + public abstract class com/github/ajalt/mordant/table/ColumnWidth { } @@ -564,8 +720,7 @@ public final class com/github/ajalt/mordant/table/CsvQuoting : java/lang/Enum { public static fun values ()[Lcom/github/ajalt/mordant/table/CsvQuoting; } -public abstract interface class com/github/ajalt/mordant/table/GridBuilder : com/github/ajalt/mordant/table/CellStyleBuilder, com/github/ajalt/mordant/table/RowHolderBuilder { - public abstract fun column (ILkotlin/jvm/functions/Function1;)V +public abstract interface class com/github/ajalt/mordant/table/GridBuilder : com/github/ajalt/mordant/table/CellStyleBuilder, com/github/ajalt/mordant/table/ColumnHolderBuilder, com/github/ajalt/mordant/table/RowHolderBuilder { } public final class com/github/ajalt/mordant/table/GridBuilder$DefaultImpls { @@ -574,8 +729,7 @@ public final class com/github/ajalt/mordant/table/GridBuilder$DefaultImpls { public static fun style (Lcom/github/ajalt/mordant/table/GridBuilder;Lcom/github/ajalt/colormath/Color;Lcom/github/ajalt/colormath/Color;ZZZZZZLjava/lang/String;)V } -public abstract interface class com/github/ajalt/mordant/table/HorizontalLayoutBuilder : com/github/ajalt/mordant/table/LinearLayoutBuilder { - public abstract fun column (ILkotlin/jvm/functions/Function1;)V +public abstract interface class com/github/ajalt/mordant/table/HorizontalLayoutBuilder : com/github/ajalt/mordant/table/ColumnHolderBuilder, com/github/ajalt/mordant/table/LinearLayoutBuilder { public abstract fun getVerticalAlign ()Lcom/github/ajalt/mordant/rendering/VerticalAlign; public abstract fun setVerticalAlign (Lcom/github/ajalt/mordant/rendering/VerticalAlign;)V } @@ -642,13 +796,12 @@ public final class com/github/ajalt/mordant/table/SectionBuilder$DefaultImpls { public abstract class com/github/ajalt/mordant/table/Table : com/github/ajalt/mordant/rendering/Widget { } -public abstract interface class com/github/ajalt/mordant/table/TableBuilder : com/github/ajalt/mordant/table/CellStyleBuilder { +public abstract interface class com/github/ajalt/mordant/table/TableBuilder : com/github/ajalt/mordant/table/CellStyleBuilder, com/github/ajalt/mordant/table/ColumnHolderBuilder { public abstract fun body (Lkotlin/jvm/functions/Function1;)V public abstract fun captionBottom (Lcom/github/ajalt/mordant/rendering/Widget;)V public abstract fun captionBottom (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextAlign;)V public abstract fun captionTop (Lcom/github/ajalt/mordant/rendering/Widget;)V public abstract fun captionTop (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextAlign;)V - public abstract fun column (ILkotlin/jvm/functions/Function1;)V public abstract fun footer (Lkotlin/jvm/functions/Function1;)V public abstract fun getBorderStyle ()Lcom/github/ajalt/mordant/rendering/TextStyle; public abstract fun getBorderType ()Lcom/github/ajalt/mordant/rendering/BorderType; @@ -1135,6 +1288,7 @@ public final class com/github/ajalt/mordant/widgets/Spinner : com/github/ajalt/m public fun (Ljava/util/List;II)V public synthetic fun (Ljava/util/List;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun advanceTick ()I + public final fun getCurrentFrame ()Lcom/github/ajalt/mordant/rendering/Widget; public final fun getTick ()I public fun measure (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/WidthRange; public fun render (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/Lines; @@ -1170,3 +1324,233 @@ public final class com/github/ajalt/mordant/widgets/UnorderedListKt { public static synthetic fun UnorderedList$default ([Ljava/lang/String;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/UnorderedList; } +public final class com/github/ajalt/mordant/widgets/Viewport : com/github/ajalt/mordant/rendering/Widget { + public fun (Lcom/github/ajalt/mordant/rendering/Widget;Ljava/lang/Integer;Ljava/lang/Integer;II)V + public synthetic fun (Lcom/github/ajalt/mordant/rendering/Widget;Ljava/lang/Integer;Ljava/lang/Integer;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun measure (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/WidthRange; + public fun render (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/Lines; +} + +public final class com/github/ajalt/mordant/widgets/progress/CachedProgressBarDefinition : com/github/ajalt/mordant/widgets/progress/ProgressBarDefinition { + public fun (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lkotlin/time/TimeSource$WithComparableMarks;)V + public fun getAlignColumns ()Z + public fun getCells ()Ljava/util/List; + public final fun getFps ()I + public fun getSpacing ()I + public final fun invalidateCache ()V +} + +public final class com/github/ajalt/mordant/widgets/progress/CachedProgressBarDefinitionKt { + public static final fun cache (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lkotlin/time/TimeSource$WithComparableMarks;)Lcom/github/ajalt/mordant/widgets/progress/CachedProgressBarDefinition; + public static synthetic fun cache$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lkotlin/time/TimeSource$WithComparableMarks;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/CachedProgressBarDefinition; +} + +public final class com/github/ajalt/mordant/widgets/progress/MultiProgressBarWidgetMaker : com/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker { + public static final field INSTANCE Lcom/github/ajalt/mordant/widgets/progress/MultiProgressBarWidgetMaker; + public fun build (Ljava/util/List;)Lcom/github/ajalt/mordant/rendering/Widget; + public final fun buildCells (Ljava/util/List;)Ljava/util/List; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressBarCell { + public fun (Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/github/ajalt/mordant/table/ColumnWidth; + public final fun component2 ()I + public final fun component3 ()Lcom/github/ajalt/mordant/rendering/TextAlign; + public final fun component4 ()Lcom/github/ajalt/mordant/rendering/VerticalAlign; + public final fun component5 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarCell; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarCell;Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarCell; + public fun equals (Ljava/lang/Object;)Z + public final fun getAlign ()Lcom/github/ajalt/mordant/rendering/TextAlign; + public final fun getColumnWidth ()Lcom/github/ajalt/mordant/table/ColumnWidth; + public final fun getContent ()Lkotlin/jvm/functions/Function1; + public final fun getFps ()I + public final fun getVerticalAlign ()Lcom/github/ajalt/mordant/rendering/VerticalAlign; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/github/ajalt/mordant/widgets/progress/ProgressBarDefinition { + public abstract fun getAlignColumns ()Z + public abstract fun getCells ()Ljava/util/List; + public abstract fun getSpacing ()I +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressBarMakerRow { + public fun (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)V + public final fun component1 ()Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public final fun component2 ()Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public final fun copy (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarMakerRow; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarMakerRow;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/widgets/progress/ProgressState;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarMakerRow; + public fun equals (Ljava/lang/Object;)Z + public final fun getDefinition ()Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public final fun getState ()Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker { + public abstract fun build (Ljava/util/List;)Lcom/github/ajalt/mordant/rendering/Widget; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMakerKt { + public static final fun build (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;[Lcom/github/ajalt/mordant/widgets/progress/ProgressBarMakerRow;)Lcom/github/ajalt/mordant/rendering/Widget; + public static final fun build (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;[Lkotlin/Pair;)Lcom/github/ajalt/mordant/rendering/Widget; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressLayoutBuilder : com/github/ajalt/mordant/widgets/progress/ProgressLayoutScope { + public fun ()V + public fun (IILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;)V + public synthetic fun (IILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun build (IZ)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public static synthetic fun build$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutBuilder;IZILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public fun cell (Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;)V + public fun getAlign ()Lcom/github/ajalt/mordant/rendering/TextAlign; + public fun getAnimationFps ()I + public fun getTextFps ()I + public fun getVerticalAlign ()Lcom/github/ajalt/mordant/rendering/VerticalAlign; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressLayoutCellsKt { + public static final fun completed (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;ZILcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;I)V + public static synthetic fun completed$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;ZILcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;IILjava/lang/Object;)V + public static final fun marquee (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;IILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ZLkotlin/jvm/functions/Function1;)V + public static final fun marquee (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;IILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Z)V + public static synthetic fun marquee$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;IILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun marquee$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;IILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ZILjava/lang/Object;)V + public static final fun percentage (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;ILcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;)V + public static synthetic fun percentage$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;ILcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ILjava/lang/Object;)V + public static final fun progressBar-vXyW-Bk (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;JLcom/github/ajalt/mordant/rendering/VerticalAlign;I)V + public static synthetic fun progressBar-vXyW-Bk$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;JLcom/github/ajalt/mordant/rendering/VerticalAlign;IILjava/lang/Object;)V + public static final fun speed (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;I)V + public static synthetic fun speed$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;IILjava/lang/Object;)V + public static final fun spinner (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Lcom/github/ajalt/mordant/widgets/Spinner;Lcom/github/ajalt/mordant/rendering/VerticalAlign;I)V + public static synthetic fun spinner$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Lcom/github/ajalt/mordant/widgets/Spinner;Lcom/github/ajalt/mordant/rendering/VerticalAlign;IILjava/lang/Object;)V + public static final fun text (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ILkotlin/jvm/functions/Function1;)V + public static final fun text (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;)V + public static synthetic fun text$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ILkotlin/jvm/functions/Function1;ILjava/lang/Object;)V + public static synthetic fun text$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;ILjava/lang/Object;)V + public static final fun timeElapsed (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;ZLcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;I)V + public static synthetic fun timeElapsed$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;ZLcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;IILjava/lang/Object;)V + public static final fun timeRemaining (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;ZZLjava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;I)V + public static synthetic fun timeRemaining$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Ljava/lang/String;ZZLjava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/VerticalAlign;IILjava/lang/Object;)V +} + +public abstract interface class com/github/ajalt/mordant/widgets/progress/ProgressLayoutScope { + public abstract fun cell (Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;)V + public abstract fun getAlign ()Lcom/github/ajalt/mordant/rendering/TextAlign; + public abstract fun getAnimationFps ()I + public abstract fun getTextFps ()I + public abstract fun getVerticalAlign ()Lcom/github/ajalt/mordant/rendering/VerticalAlign; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressLayoutScope$DefaultImpls { + public static synthetic fun cell$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressLayoutScope;Lcom/github/ajalt/mordant/table/ColumnWidth;ILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressLayoutScopeKt { + public static final fun ProgressBarDefinition (Ljava/util/List;IZ)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public static final fun build (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/widgets/progress/ProgressState;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/rendering/Widget; + public static final fun build (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/rendering/Widget; + public static final fun build (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;)Lcom/github/ajalt/mordant/rendering/Widget; + public static synthetic fun build$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Lcom/github/ajalt/mordant/widgets/progress/ProgressState;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/rendering/Widget; + public static synthetic fun build$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/rendering/Widget; + public static synthetic fun build$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition;Ljava/lang/Object;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker;ILjava/lang/Object;)Lcom/github/ajalt/mordant/rendering/Widget; + public static final fun progressBarContextLayout (IZIILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public static synthetic fun progressBarContextLayout$default (IZIILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public static final fun progressBarLayout (IZIILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; + public static synthetic fun progressBarLayout$default (IZIILcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/VerticalAlign;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressBarDefinition; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressState { + public fun (Ljava/lang/Object;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/TaskId;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/TaskId;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Ljava/lang/Long; + public final fun component3 ()J + public final fun component4 ()Lkotlin/time/ComparableTimeMark; + public final fun component5 ()Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status; + public final fun component6 ()Ljava/lang/Double; + public final fun component7 ()Lcom/github/ajalt/mordant/widgets/progress/TaskId; + public final fun copy (Ljava/lang/Object;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/TaskId;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;Ljava/lang/Object;Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;Lcom/github/ajalt/mordant/widgets/progress/TaskId;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnimationTime ()Lkotlin/time/ComparableTimeMark; + public final fun getCompleted ()J + public final fun getContext ()Ljava/lang/Object; + public final fun getSpeed ()Ljava/lang/Double; + public final fun getStatus ()Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status; + public final fun getTaskId ()Lcom/github/ajalt/mordant/widgets/progress/TaskId; + public final fun getTotal ()Ljava/lang/Long; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract class com/github/ajalt/mordant/widgets/progress/ProgressState$Status { +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressState$Status$Finished : com/github/ajalt/mordant/widgets/progress/ProgressState$Status { + public fun (Lkotlin/time/ComparableTimeMark;Lkotlin/time/ComparableTimeMark;)V + public final fun component1 ()Lkotlin/time/ComparableTimeMark; + public final fun component2 ()Lkotlin/time/ComparableTimeMark; + public final fun copy (Lkotlin/time/ComparableTimeMark;Lkotlin/time/ComparableTimeMark;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Finished; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Finished;Lkotlin/time/ComparableTimeMark;Lkotlin/time/ComparableTimeMark;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Finished; + public fun equals (Ljava/lang/Object;)Z + public final fun getFinishTime ()Lkotlin/time/ComparableTimeMark; + public final fun getStartTime ()Lkotlin/time/ComparableTimeMark; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressState$Status$NotStarted : com/github/ajalt/mordant/widgets/progress/ProgressState$Status { + public static final field INSTANCE Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$NotStarted; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressState$Status$Paused : com/github/ajalt/mordant/widgets/progress/ProgressState$Status { + public fun (Lkotlin/time/ComparableTimeMark;Lkotlin/time/ComparableTimeMark;)V + public final fun component1 ()Lkotlin/time/ComparableTimeMark; + public final fun component2 ()Lkotlin/time/ComparableTimeMark; + public final fun copy (Lkotlin/time/ComparableTimeMark;Lkotlin/time/ComparableTimeMark;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Paused; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Paused;Lkotlin/time/ComparableTimeMark;Lkotlin/time/ComparableTimeMark;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Paused; + public fun equals (Ljava/lang/Object;)Z + public final fun getPauseTime ()Lkotlin/time/ComparableTimeMark; + public final fun getStartTime ()Lkotlin/time/ComparableTimeMark; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressState$Status$Running : com/github/ajalt/mordant/widgets/progress/ProgressState$Status { + public fun (Lkotlin/time/ComparableTimeMark;)V + public final fun component1 ()Lkotlin/time/ComparableTimeMark; + public final fun copy (Lkotlin/time/ComparableTimeMark;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Running; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Running;Lkotlin/time/ComparableTimeMark;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status$Running; + public fun equals (Ljava/lang/Object;)Z + public final fun getStartTime ()Lkotlin/time/ComparableTimeMark; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/widgets/progress/ProgressStateKt { + public static final fun ProgressState (Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public static synthetic fun ProgressState$default (Ljava/lang/Long;JLkotlin/time/ComparableTimeMark;Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;Ljava/lang/Double;ILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/progress/ProgressState; + public static final fun calculateTimeElapsed (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)Lkotlin/time/Duration; + public static final fun calculateTimeRemaining (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;Z)Lkotlin/time/Duration; + public static synthetic fun calculateTimeRemaining$default (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;ZILjava/lang/Object;)Lkotlin/time/Duration; + public static final fun frameCount (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;I)I + public static final fun getFinishTime (Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;)Lkotlin/time/ComparableTimeMark; + public static final fun getPauseTime (Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;)Lkotlin/time/ComparableTimeMark; + public static final fun getStartTime (Lcom/github/ajalt/mordant/widgets/progress/ProgressState$Status;)Lkotlin/time/ComparableTimeMark; + public static final fun isFinished (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)Z + public static final fun isIndeterminate (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)Z + public static final fun isPaused (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)Z + public static final fun isRunning (Lcom/github/ajalt/mordant/widgets/progress/ProgressState;)Z +} + +public final class com/github/ajalt/mordant/widgets/progress/TaskId { + public fun ()V +} + diff --git a/mordant/build.gradle.kts b/mordant/build.gradle.kts index 42002dccf..67a2bc8d5 100644 --- a/mordant/build.gradle.kts +++ b/mordant/build.gradle.kts @@ -5,30 +5,20 @@ plugins { kotlin { sourceSets { - val commonMain by getting { - dependencies { - api(libs.colormath) - implementation(libs.markdown) - implementation(libs.jna.core) - } + commonMain.dependencies { + api(libs.colormath) + implementation(libs.markdown) + implementation(libs.jna.core) } - val commonTest by getting { - dependencies { - implementation(kotlin("test")) - implementation(libs.kotest) - } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotest) } - - val jvmMain by getting { - dependencies { - compileOnly(libs.graalvm.svm) - } + jvmMain.dependencies { + compileOnly(libs.graalvm.svm) } - - val jvmTest by getting { - dependencies { - api(libs.systemrules) - } + jvmTest.dependencies { + api(libs.systemrules) } } } 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 ef96816d1..c788f8918 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 @@ -1,5 +1,9 @@ package com.github.ajalt.mordant.animation +import com.github.ajalt.mordant.internal.FAST_ISATTY +import com.github.ajalt.mordant.internal.MppAtomicRef +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.update import com.github.ajalt.mordant.rendering.OverflowWrap import com.github.ajalt.mordant.rendering.TextAlign import com.github.ajalt.mordant.rendering.Whitespace @@ -10,6 +14,7 @@ import com.github.ajalt.mordant.terminal.TerminalInfo import com.github.ajalt.mordant.terminal.TerminalInterceptor import com.github.ajalt.mordant.widgets.EmptyWidget import com.github.ajalt.mordant.widgets.Text +import com.github.ajalt.mordant.widgets.progress.progressBarLayout /** * An Animation renders a widget to the screen each time [update] is called, clearing the render @@ -22,40 +27,60 @@ import com.github.ajalt.mordant.widgets.Text * 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], animate a + * [progressBarLayout], or by creating a subclass. + * + * Note that although this class's state is thread safe, calling [update] concurrently will likely + * cause garbled output, so usage of this class should be serialized. */ abstract class Animation( - /** - * By default, the animation will include a trailing linebreak. If you set this to false, you - * won't be able to use multiple animations simultaneously. - */ + @Deprecated("This parameter is ignored; animations never print a trailing linebreak.") private val trailingLinebreak: Boolean = true, - private val terminal: Terminal, + val terminal: Terminal, ) { - private var size: Pair? = null - private var text: String? = null - private var needsClear = false - private var interceptorInstalled = false + private data class State( + val size: Size? = null, + val lastSize: Size? = null, + val lastTerminalSize: Size? = null, + val text: String? = null, + val interceptorInstalled: Boolean = false, + val firstDraw: Boolean = true, + ) - // Don't move the cursor the first time the animation is drawn - private var firstDraw = true + private val state = MppAtomicRef(State()) private val interceptor: TerminalInterceptor = TerminalInterceptor { req -> - val t = text ?: return@TerminalInterceptor req + val terminalSize = Size(terminal.info.width, terminal.info.height) + val (st, _) = state.update { + copy( + firstDraw = false, + lastSize = size, + lastTerminalSize = terminalSize, + ) + } + val animationText = st.text ?: return@TerminalInterceptor req val newText = buildString { - if (!firstDraw) { - getCursorMoves(needsClear || req.text.isNotEmpty())?.let { append(it) } - } - firstDraw = false - if (req.text.isNotEmpty()) { - appendLine(req.text) + // move the cursor to the start of the widget, then append the request (which might + // start with moves if it's an animation), then our text + getCursorMoves( + firstDraw = st.firstDraw, + clearScreen = req.text.isNotEmpty(), + lastSize = st.lastSize, + size = st.size, + terminalSize = terminalSize, + lastTerminalSize = st.lastTerminalSize, + extraUp = if (req.text.startsWith("\r")) 1 else 0, // it's another animation + )?.let { append(it) } + when { + req.text.endsWith("\n") -> append(req.text) + req.text.isNotEmpty() -> appendLine(req.text) } - append(t) + append(animationText) } + PrintRequest( text = newText, - trailingLinebreak = trailingLinebreak && !terminal.info.crClearsLine, + trailingLinebreak = false, stderr = req.stderr ) } @@ -68,9 +93,17 @@ abstract class Animation( * Future calls to [update] will cause the animation to resume. */ fun clear() { - stop() - getCursorMoves(clearScreen = true)?.let { terminal.rawPrint(it) } - size = null + val (old, _) = doStop(clearSize = true, newline = false) + getCursorMoves( + firstDraw = false, + clearScreen = true, + lastSize = old.size, + size = null, + terminalSize = Size(terminal.info.width, terminal.info.height), + lastTerminalSize = old.lastTerminalSize, + // if we previously stopped, we need to move up past the final newline we added + extraUp = if (old.firstDraw && old.size != null) 1 else 0, + )?.let { terminal.rawPrint(it) } } /** @@ -80,18 +113,25 @@ abstract class Animation( * this animation. * * Future calls to [update] will cause the animation to start again. - * - * ### Note - * - * If running on JVM when [TerminalInfo.crClearsLine] is true (such as on the IntelliJ built-in - * console), this will not print a trailing newline, leaving the cursor on the same line as the - * animation. */ fun stop() { - if (interceptorInstalled) terminal.removeInterceptor(interceptor) - interceptorInstalled = false - firstDraw = true - text = null + doStop(clearSize = false, newline = true) + } + + private fun doStop(clearSize: Boolean, newline: Boolean): Pair { + val (old, new) = state.update { + copy( + interceptorInstalled = false, + firstDraw = true, + text = null, + size = if (clearSize) null else size, + ) + } + if (old.interceptorInstalled) { + terminal.removeInterceptor(interceptor) + if (newline) terminal.println() + } + return old to new } /** @@ -101,30 +141,64 @@ abstract class Animation( * place. */ fun update(data: T) { - if (!interceptorInstalled && terminal.info.outputInteractive) { - terminal.addInterceptor(interceptor) - } - interceptorInstalled = true + if (FAST_ISATTY) terminal.info.updateTerminalSize() + val rendered = renderData(data).render(terminal) val height = rendered.height val width = rendered.width - // To avoid flickering don't clear the screen if the render will completely cover the last frame - needsClear = size?.let { (h, w) -> height < h || width < w } ?: false - text = terminal.render(rendered) - // Print an empty renderable to trigger our interceptor, which will add the rendered text + val (old, _) = state.update { + copy( + size = Size(width, height), + lastSize = size, + interceptorInstalled = true, + text = terminal.render(rendered) + ) + } + if (!old.interceptorInstalled && terminal.info.outputInteractive) { + terminal.addInterceptor(interceptor) + } + // Print an empty widget to trigger our interceptor, which will add the rendered text terminal.print(EmptyWidget) - // Update the size now that the old frame has been cleared - size = height to width } - private fun getCursorMoves(clearScreen: Boolean): String? { - val (height, _) = size ?: return null + private fun getCursorMoves( + firstDraw: Boolean, + clearScreen: Boolean, + lastSize: Size?, + size: Size?, + terminalSize: Size, + lastTerminalSize: Size?, + extraUp: Int = 0, + ): String? { + if (firstDraw || lastSize == null) return null return terminal.cursor.getMoves { startOfLine() - up(if (trailingLinebreak && !terminal.info.crClearsLine) height else height - 1) - if (clearScreen && (height > 1 || !terminal.info.crClearsLine)) { - clearScreenAfterCursor() + + if (terminal.info.crClearsLine) { + // IntelliJ doesn't support cursor moves, so this is all we can do + return@getMoves + } + + val terminalShrank = lastTerminalSize != null + && terminalSize.width < lastTerminalSize.width + && terminalSize.width < lastSize.width + val widgetShrank = size != null && ( + size.width < lastSize.width + || size.height < lastSize.height + ) + val up = if (terminalShrank) { + // The terminal shrank and caused the text to wrap, we need to move back to the + // start of the text + lastSize.height * (lastSize.width.toDouble() / terminalSize.width).toInt() + } else { + (lastSize.height - 1).coerceAtLeast(0) } + + up(up + extraUp) + + // To avoid flickering don't clear the screen if the render will completely cover + // the last frame + if (terminalShrank || widgetShrank || clearScreen) clearScreenAfterCursor() } } } @@ -132,8 +206,6 @@ abstract class Animation( /** * Create an [Animation] that uses the [draw] function to render objects of type [T]. * - * @param trailingLinebreak By default, the animation will include a trailing linebreak. If you set - * this to false, you won't be able to use multiple animations simultaneously. * @see Animation */ inline fun Terminal.animation( @@ -148,9 +220,6 @@ inline fun Terminal.animation( /** * Create an [Animation] that wraps the result of the [draw] function into a [Text] widget and * renders it. - * - * @param trailingLinebreak By default, the animation will include a trailing linebreak. If you set - * this to false, you won't be able to use multiple animations simultaneously. */ inline fun Terminal.textAnimation( whitespace: Whitespace = Whitespace.PRE, diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt new file mode 100644 index 000000000..1d614d760 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt @@ -0,0 +1,80 @@ +package com.github.ajalt.mordant.animation + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +interface Refreshable { + /** + * `true` if this animation has finished and should be stopped or cleared. + */ + val finished: Boolean + + /** + * Draw the animation to the screen. + * + * This is called automatically when the animation is running, so you don't usually need to call + * it manually. + * + * @param refreshAll If `true`, refresh all contents, ignoring their fps. + */ + fun refresh(refreshAll: Boolean = false) +} + +/** + * A version of [Animation] that has a parameterless [refresh] method instead of `update`. + * + * Implementations will need to handle concurrently updating their state. + */ +interface RefreshableAnimation : Refreshable { + + /** + * Stop this animation and remove it from the screen. + * + * Future calls to [refresh] will cause the animation to resume. + */ + fun clear() + + /** + * 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 [refresh] will cause the animation to start again. + */ + fun stop() + + /** + * The rate, in Hz, that this animation should be refreshed, or 0 if it should not be refreshed + * automatically. + */ + val fps: Int get() = 5 +} + +/** The time between refreshes. This is `1 / refreshRate` */ +val RefreshableAnimation.refreshPeriod: Duration + get() = (1.0 / fps).seconds + +/** + * Convert this [Animation] to a [RefreshableAnimation]. + * + * ### Example + * ``` + * terminal.animation {/*...*/}.asRefreshable().animateOnThread() + * ``` + * + * @param fps The rate at which the animation should be refreshed. + * @param finished A function that returns `true` if the animation has finished. + */ +inline fun Animation.asRefreshable( + fps: Int = 5, + crossinline finished: () -> Boolean = { false }, +): RefreshableAnimation { + return object : RefreshableAnimation { + override fun refresh(refreshAll: Boolean) = update(Unit) + override fun clear() = this@asRefreshable.clear() + override fun stop() = this@asRefreshable.stop() + override val finished: Boolean get() = finished() + override val fps: Int get() = fps + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/MultiProgressBarAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/MultiProgressBarAnimation.kt new file mode 100644 index 000000000..df4ff04c2 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/MultiProgressBarAnimation.kt @@ -0,0 +1,258 @@ +package com.github.ajalt.mordant.animation.progress + +import com.github.ajalt.mordant.animation.RefreshableAnimation +import com.github.ajalt.mordant.animation.animation +import com.github.ajalt.mordant.internal.MppAtomicRef +import com.github.ajalt.mordant.internal.update +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.widgets.progress.* +import com.github.ajalt.mordant.widgets.progress.ProgressState.Status +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit.SECONDS +import kotlin.time.TimeSource + +class MultiProgressBarAnimation( + /** The terminal to render the animation to */ + val terminal: Terminal, + /** + * If `true`, the animation will be cleared when all tasks are finished. + * + * Otherwise, the animation will stop when all tasks are finished, but remain on the screen. + */ + private val clearWhenFinished: Boolean = false, + /** + * The duration over which to estimate the speed of the progress tasks. + * + * This estimate will be a rolling average over this duration. + */ + private val speedEstimateDuration: Duration = 30.seconds, + /** + * The widget maker to use to lay out the progress bars. + */ + private val maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker, + /** + * The time source to use for the animation. + */ + private val timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, +) : RefreshableAnimation, ProgressBarAnimation { + private data class State(val visible: Boolean, val tasks: List>) + + private val state = MppAtomicRef(State(true, emptyList())) + private val animationTime = timeSource.markNow() + private val animation = terminal.animation>> { maker.build(it) } + + override fun addTask( + definition: ProgressBarDefinition, + context: T, + total: Long?, + completed: Long, + start: Boolean, + visible: Boolean, + ): ProgressTask { + val task = ProgressTaskImpl( + context = context, + total = total, + completed = completed, + visible = visible, + now = timeSource.markNow(), + start = start, + definition = definition.cache(timeSource), + animationTime = animationTime, + timeSource = timeSource, + speedEstimateDuration = speedEstimateDuration + ) + state.update { copy(tasks = tasks + task) } + return task + } + + override fun removeTask(taskId: TaskId): Boolean { + val (old, _) = state.update { copy(tasks = tasks.filter { it.id != taskId }) } + return old.tasks.any { it.id == taskId } + } + + override fun refresh(refreshAll: Boolean) { + val s = state.value + if (!s.visible) return + if (refreshAll) invalidateAllCaches() + animation.update(s.tasks.filter { it.visible }.map { it.makeRow() }) + if (finished) { + if (clearWhenFinished) animation.clear() else animation.stop() + } + } + + override fun stop() { + animation.stop() + } + + override fun clear() { + animation.clear() + } + + override val finished: Boolean + get() = state.value.tasks.all { it.finished } + + override val fps: Int + get() = state.value.tasks.maxOf { it.definition.fps } + + private fun invalidateAllCaches() { + state.value.tasks.forEach { it.definition.invalidateCache() } + } +} + +private class HistoryEntry(val time: ComparableTimeMark, val completed: Long) + +private data class TaskState( + val context: T, + val total: Long?, + val completed: Long, + val visible: Boolean, + val samples: List, // newest samples are at the end + val status: Status, + val finishedSpeed: Double?, +) { + constructor( + context: T, + total: Long?, + completed: Long, + visible: Boolean, + now: ComparableTimeMark, + start: Boolean, + ) : this( + context = context, + total = total, + completed = completed, + visible = visible, + samples = if (start) listOf(HistoryEntry(now, completed)) else emptyList(), + status = if (start) Status.Running(now) else Status.NotStarted, + finishedSpeed = null, + ) +} + +private class UpdateScopeImpl( + override var context: T, + override var completed: Long, + override var total: Long?, + override var visible: Boolean, + override var started: Boolean, + override var paused: Boolean, +) : ProgressTaskUpdateScope + +private class ProgressTaskImpl( + context: T, + total: Long?, + completed: Long, + visible: Boolean, + now: ComparableTimeMark, + start: Boolean, + val definition: CachedProgressBarDefinition, + private val animationTime: ComparableTimeMark, + private val timeSource: TimeSource.WithComparableMarks, + private val speedEstimateDuration: Duration, +) : ProgressTask { + override val id: TaskId = TaskId() + private val state = MppAtomicRef(TaskState(context, total, completed, visible, now, start)) + + override fun update(block: ProgressTaskUpdateScope.() -> Unit) { + state.update { + val scope = UpdateScopeImpl( + context = context, + completed = completed, + total = total, + visible = visible, + started = status !is Status.NotStarted, + paused = status is Status.Paused + ) + scope.block() + + // Remove samples older than the speed estimate duration + val oldestSampleTime = timeSource.markNow() - speedEstimateDuration + val entry = HistoryEntry(timeSource.markNow(), scope.completed) + val samples = samples.dropWhile { it.time < oldestSampleTime } + entry + val total = scope.total + + val startTime = status.pauseTime ?: timeSource.markNow() + val finishTime = status.finishTime ?: timeSource.markNow() + val pauseTime = status.pauseTime ?: timeSource.markNow() + + val status = when { + total != null && scope.completed >= total -> Status.Finished(startTime, finishTime) + scope.started && scope.paused -> Status.Paused(startTime, pauseTime) + scope.started -> Status.Running(startTime) + else -> Status.NotStarted + } + + val finishedSpeed = when (status) { + is Status.Finished -> finishedSpeed ?: estimateSpeed(startTime, samples) + else -> null + } + + copy( + samples = samples, + context = scope.context, + completed = scope.completed, + total = total, + visible = scope.visible, + status = status, + finishedSpeed = finishedSpeed, + ) + } + } + + override fun reset( + start: Boolean, + block: ProgressTaskUpdateScope.() -> Unit, + ) { + state.update { + val s = state.value + val scope = UpdateScopeImpl(s.context, 0, s.total, s.visible, start, false) + scope.block() + TaskState( + context = scope.context, + total = scope.total, + completed = scope.completed, + visible = scope.visible, + now = timeSource.markNow(), + start = scope.started, + ) + } + definition.invalidateCache() + } + + override fun makeState(): ProgressState { + return state.value.run { + ProgressState( + context = context, + total = total, + completed = completed, + animationTime = animationTime, + status = status, + speed = finishedSpeed ?: estimateSpeed(status.startTime, samples), + taskId = id, + ) + } + } + + fun makeRow(): ProgressBarMakerRow { + return ProgressBarMakerRow(definition, makeState()) + } + + override val finished: Boolean get() = state.value.status is Status.Finished + override val started: Boolean get() = state.value.status !is Status.NotStarted + override val paused: Boolean get() = state.value.status is Status.Paused + override val visible: Boolean get() = state.value.visible + override val context: T get() = state.value.context + override val completed: Long get() = state.value.completed + override val total: Long? get() = state.value.total +} + +private fun estimateSpeed( + startedTime: ComparableTimeMark?, + samples: List, +): Double? { + if (startedTime == null || samples.size < 2) return null + val sampleTimespan = samples.first().time.elapsedNow().toDouble(SECONDS) + val complete = samples.last().completed - samples.first().completed + return if (complete <= 0 || sampleTimespan <= 0.0) null else complete / sampleTimespan +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt new file mode 100644 index 000000000..46021dbd6 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt @@ -0,0 +1,159 @@ +package com.github.ajalt.mordant.animation.progress + +import com.github.ajalt.mordant.animation.Refreshable +import com.github.ajalt.mordant.widgets.progress.ProgressBarCell +import com.github.ajalt.mordant.widgets.progress.ProgressBarDefinition +import com.github.ajalt.mordant.widgets.progress.ProgressState +import com.github.ajalt.mordant.widgets.progress.TaskId + +interface ProgressTaskUpdateScope { + /** The context is used to pass custom information to the task */ + var context: T + + /** The number of units of work that have been completed */ + var completed: Long + + /** The total number of units of work, or null if the total is unknown */ + var total: Long? + + /** Whether the task should be visible in the progress bar */ + var visible: Boolean + + /** Whether the task has been started */ + var started: Boolean + + /** Whether the task is currently paused */ + var paused: Boolean +} + +interface ProgressTask { + /** + * Update the task's state. + * + * If the completed count is equal to the total, the task will be marked as [finished]. + * + * If the task is already finished, this method will still update the task's state, but it will + * remain marked as finished. Use [reset] if you want to start the task again. + */ + fun update(block: ProgressTaskUpdateScope.() -> Unit) + + /** + * Reset the task so its completed count is 0 and its clock is reset. + * + * @param start If true, start the task after resetting it and running [block]. + * @param block A block to [update] the task's state after resetting it. + */ + fun reset( + start: Boolean = true, + block: ProgressTaskUpdateScope.() -> Unit = {}, + ) + + /** Create a [ProgressState] for this task's current state */ + fun makeState(): ProgressState + + /** `true` if this task's [completed] count is equal to its [total] */ + val finished: Boolean + + /** The context is used to pass custom information to the task */ + val context: T + + /** The number of units of work that have been completed */ + val completed: Long + + /** The total number of units of work, or null if the total is unknown */ + val total: Long? + + /** Whether the task should be visible in the progress bar */ + val visible: Boolean + + /** Whether the task has been started */ + val started: Boolean + + /** Whether the task is currently paused */ + val paused: Boolean + + /** The unique id of this task */ + val id: TaskId +} + +/** + * Advance the completed progress of this task by [amount]. + * + * This is a shortcut for `update { completed += amount }`. + */ +fun ProgressTask<*>.advance(amount: Long = 1) = update { completed += amount } + +/** + * Advance the completed progress of this task by [amount]. + * + * This is a shortcut for `update { completed += amount }`. + */ +fun ProgressTask<*>.advance(amount: Number) = advance(amount.toLong()) + +/** + * Set the completed progress of this task to [completed]. + * + * This is a shortcut for `update { this.completed += completed }`. + */ +fun ProgressTask<*>.update(completed: Long) = update { this.completed = completed } + +/** + * Set the completed progress of this task to [completed]. + * + * This is a shortcut for `update { this.completed += completed }`. + */ +fun ProgressTask<*>.update(completed: Number) = update(completed.toLong()) + +// This isn't a RefreshableAnimation because the coroutine animator needs its methods to be +// suspending +/** + * An animation that can draw one or more progress [tasks][addTask] to the screen. + */ +interface ProgressBarAnimation : Refreshable { + /** + * Add a new task to the progress bar with the given [definition] and [context]. + * + * @param definition The definition of the progress bar to add + * @param context The context to pass to the task + * @param total The total number of steps needed to complete the progress task, or `null` if it is indeterminate. + * @param completed The number of steps currently completed in the progress task. + * @param start If `true`, start the task immediately. + * @param visible If `false`, the task will not be drawn to the screen. + */ + fun addTask( + definition: ProgressBarDefinition, + context: T, + total: Long? = null, + completed: Long = 0, + start: Boolean = true, + visible: Boolean = true, + ): ProgressTask + + /** + * Remove a task with the given [taskId] from the progress bar. + * + * @return `true` if the task was removed, `false` if it was not found. + */ + fun removeTask(taskId: TaskId): Boolean +} + +/** + * Remove a task from the progress bar. + * + * @return `true` if the task was removed, `false` if it was not found. + */ +fun ProgressBarAnimation.removeTask(task: ProgressTask<*>) = removeTask(task.id) + +/** + * Add a new task to the progress bar with the given [definition]. + */ +fun ProgressBarAnimation.addTask( + definition: ProgressBarDefinition, + total: Long? = null, + completed: Long = 0, + start: Boolean = true, + visible: Boolean = true, +): ProgressTask { + return addTask(definition, Unit, total, completed, start, visible) +} + 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 index 7470817ae..6fa994797 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/BlankWidgetWrapper.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/BlankWidgetWrapper.kt @@ -3,6 +3,7 @@ package com.github.ajalt.mordant.internal import com.github.ajalt.mordant.rendering.* import com.github.ajalt.mordant.terminal.Terminal +/** A widget that displays blank space the same size as an [inner] widget */ internal class BlankWidgetWrapper(private val inner: Widget) : Widget { override fun measure(t: Terminal, width: Int): WidthRange = inner.measure(t, width) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt index 178d3c015..eff78ae7f 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt @@ -28,3 +28,9 @@ internal val DEFAULT_PADDING = Padding(0) @Suppress("RegExpRedundantEscape") // JS requires escaping the lone `]` at the beginning of the pattern, so we can't use $OSC internal val ANSI_RE = Regex("""$ESC\][^$ESC]*$ESC\\|$ESC(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])""") internal const val HYPERLINK_RESET = "__mordant_reset__" + +internal data class Size(val width: Int, val height: Int) { + override fun toString(): String { + return "${width}x$height" + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Formatting.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Formatting.kt index 7ac77fb9f..95be75776 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Formatting.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Formatting.kt @@ -1,33 +1,46 @@ package com.github.ajalt.mordant.internal +import kotlin.math.pow + private const val SI_PREFIXES = "KMGTEPZY" /** * Return a list of all numbers in [nums] formatted as a string, and the unit they were reduced with. + * + * All numbers will be formatted to the same unit. + * + * @param precision The number of decimal places to include in the formatted numbers + * @param nums The numbers to format */ -internal fun formatMultipleWithSiSuffixes(decimals: Int, vararg nums: Double): Pair, String> { - var n = nums.maxOrNull()!! - var suffix = "" - for (c in SI_PREFIXES) { - if (n < 1000) { - break - } - n /= 1000 - for (i in nums.indices) { - nums[i] = nums[i] / 1000 +internal fun formatMultipleWithSiSuffixes( + precision: Int, truncateDecimals: Boolean, vararg nums: Double, +): Pair, String> { + require(precision >= 0) { "precision must be >= 0" } + val largest = nums.max() + var divisor = 1 + var prefix = "" + for (s in SI_PREFIXES) { + if (largest / divisor < 1000) break + divisor *= 1000 + prefix = s.toString() + } + + val exp = 10.0.pow(precision) + val formatted = nums.map { + val n = it / divisor + val i = n.toInt() + val d = ((n - i) * exp).toInt() + when { + truncateDecimals && (precision == 0 || divisor == 1 && d == 0) -> i.toString() + else -> "$i.${d.toString().padEnd(precision, '0')}" } - suffix = c.toString() } - return nums.map { num -> - val s = num.toString() - val i = s.indexOf('.') - if (i >= 0) s.take(i + decimals + 1) else "$s.0" - } to suffix + return formatted to prefix } /** Return this number formatted as a string, suffixed with its SI unit */ -internal fun Double.formatWithSiSuffix(decimals: Int): String { - return formatMultipleWithSiSuffixes(decimals, this).let { it.first.first() + it.second } +internal fun Double.formatWithSiSuffix(precision: Int): String { + return formatMultipleWithSiSuffixes(precision, false, this).let { it.first.first() + it.second } } /** Return the number of seconds represented by [nanos] as a `Double` */ 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 0977785b6..5eb1a30ed 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 @@ -8,12 +8,30 @@ internal interface MppAtomicInt { fun set(value: Int) } +internal interface MppAtomicRef { + val value: T + fun compareAndSet(expected: T, newValue: T): Boolean + fun getAndSet(newValue: T): T +} + +/** Update the reference via spin lock, spinning up to [attempts] times. */ +internal inline fun MppAtomicRef.update(attempts: Int = 99, block: T.() -> T): Pair { + repeat(attempts) { + val old = value + val newValue = block(old) + if (compareAndSet(old, newValue)) return old to newValue + } + throw ConcurrentModificationException("Failed to update state due to concurrent updates") +} + +internal expect fun MppAtomicRef(value: T): MppAtomicRef + internal expect fun MppAtomicInt(initial: Int): MppAtomicInt internal expect fun getEnv(key: String): String? -/** Returns pair of [width, height], or null if it can't be detected */ -internal expect fun getTerminalSize(): Pair? +/** Return a pair of [width, height], or null if it can't be detected */ +internal expect fun getTerminalSize(): Size? internal expect fun runningInIdeaJavaAgent(): Boolean @@ -35,4 +53,4 @@ internal expect fun sendInterceptedPrintRequest( interceptors: List, ) -internal expect inline fun synchronizeJvm(lock: Any, block: () -> Unit) +internal expect val FAST_ISATTY: Boolean diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/gen/emojiseqtable.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/gen/emojiseqtable.kt index dc82c23b4..71102a255 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/gen/emojiseqtable.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/gen/emojiseqtable.kt @@ -2827,7 +2827,7 @@ private fun buildSeqTrie(): IntTrie { val root = IntTrie() for (seq in sequences) { var node = root - for (i in 0 until seq.lastIndex) { + for (i in 0.., val endStyle: TextStyle) : List by constructor(spans: List) : this(spans, spans.lastOrNull()?.style ?: DEFAULT_STYLE) } +internal val Line.startStyle: TextStyle get() = firstOrNull()?.style ?: DEFAULT_STYLE + /** * A lines, where each line is a list of [Span]s. * @@ -57,6 +59,7 @@ internal fun flatLine(vararg parts: Any?): Line { when (part) { null -> { } + is Collection<*> -> part.mapTo(line) { it as Span } is Span -> line.add(part) else -> error("not a span: $part") @@ -67,87 +70,128 @@ internal fun flatLine(vararg parts: Any?): Line { /** * Pad or crop every line so its width is exactly [newWidth], and add or remove lines so its height - * is exactly [newHeight] + * is exactly [newHeight]. + * + * If [newWidth] is null, the width if each line will be unchanged. */ internal fun Lines.setSize( newWidth: Int, newHeight: Int = lines.size, verticalAlign: VerticalAlign = TOP, textAlign: TextAlign = NONE, + scrollRight: Int = 0, + scrollDown: Int = 0, ): Lines { - if (newHeight == 0) return EMPTY_LINES - if (newWidth == 0) return Lines(List(newHeight) { EMPTY_LINE }) + if (newHeight <= 0) return EMPTY_LINES + if (newWidth <= 0) return Lines(List(newHeight) { EMPTY_LINE }) + val emptyLine = Line(listOf(Span.space(newWidth))) - val heightToAdd = (newHeight - lines.size).coerceAtLeast(0) + val offsetLines = when { + scrollDown == 0 -> lines + scrollDown !in -lines.lastIndex..lines.lastIndex -> emptyList() + scrollDown < 0 -> buildList(lines.size - scrollDown) { + repeat(-scrollDown) { add(emptyLine) } + addAll(lines) + } - val emptyLine = Line(listOf(Span.space(newWidth))) - val lines = ArrayList(newHeight) + else -> lines.subList(scrollDown, lines.size) + } - val topEmptyLines = when (verticalAlign) { + val heightToAdd = (newHeight - offsetLines.size).coerceAtLeast(0) + val topEmptyLineCount = when (verticalAlign) { TOP -> 0 MIDDLE -> heightToAdd / 2 + heightToAdd % 2 BOTTOM -> heightToAdd } - repeat(topEmptyLines) { - lines.add(emptyLine) - } + return Lines(buildList(newHeight) { + repeat(topEmptyLineCount) { add(emptyLine) } + offsetLines.subList(0, newHeight.coerceAtMost(offsetLines.size)).mapTo(this) { + Line(resizeLine(it, scrollRight, newWidth, textAlign)) + } + repeat(newHeight - topEmptyLineCount - offsetLines.size) { add(emptyLine) } + }) +} - line@ for ((i, line) in this.lines.withIndex()) { - if (i >= newHeight) break +private fun resizeLine( + line: Line, scrollRight: Int, newWidth: Int, textAlign: TextAlign, +): List { + var width = 0 + var offset = 0 + val inputLine = when { + scrollRight < 0 -> listOf(Span.space(-scrollRight, line.startStyle)) + line.spans + else -> line.spans + } + var startIndex = 0 + var endIndex = inputLine.size + var startSpan: Span? = null + var endSpan: Span? = null + for ((j, span) in inputLine.withIndex()) { + when { + // If we have a right scroll offset, skip spans until we reach it + scrollRight > 0 && offset + span.cellWidth < scrollRight -> { + offset += span.cellWidth + startIndex = j + 1 + } - var width = 0 - for ((j, span) in line.withIndex()) { - when { - width + span.cellWidth <= newWidth -> { - width += span.cellWidth - } - width == newWidth -> { - lines.add(Line(line.subList(0, j))) - continue@line - } - else -> { - lines.add(Line(line.subList(0, j) + span.take(newWidth - width))) - continue@line + // If we have a right scroll offset, and this span is the one that contains it, split it + scrollRight > 0 && offset < scrollRight -> { + if (offset + span.cellWidth > scrollRight) { + startSpan = span.drop(scrollRight - offset).take(newWidth - width) + startIndex = j + width += startSpan.cellWidth } + offset = scrollRight + startIndex = j + 1 } - } - val remainingWidth = newWidth - width - if (remainingWidth > 0) { - val beginStyle = line.firstOrNull()?.style ?: line.endStyle - val endStyle = line.endStyle + // We're past the offset, so add spans until we reach the new width + width + span.cellWidth <= newWidth -> { + width += span.cellWidth + endIndex = j + 1 + } - when (textAlign) { - CENTER, JUSTIFY -> { - val l = Span.space(remainingWidth / 2, beginStyle) - val r = Span.space(remainingWidth / 2 + remainingWidth % 2, endStyle) - lines.add(Line(listOf(listOf(l), line, listOf(r)).flatten())) - } - LEFT -> { - lines.add(Line(line + Span.space(remainingWidth, endStyle))) - } - NONE -> { - lines.add(Line(line + Span.space(remainingWidth))) // Spaces aren't styled in this alignment - } - RIGHT -> { - lines.add(Line(listOf(Span.space(remainingWidth, beginStyle)) + line)) + // We've reached the new width before the end of the line, so make a new line + else -> { + endIndex = j + if (width < newWidth) { + endSpan = span.take(newWidth - width) + width += endSpan.cellWidth } + break } - } else { - lines.add(line) } } - if (newHeight != lines.size) { - if (newHeight < lines.size) { - return Lines(lines.take(newHeight)) - } else { - val line = if (newWidth == 0) EMPTY_LINE else Line(listOf(Span.space(newWidth))) - repeat(newHeight - lines.size) { - lines.add(line) - } + // Truncate the line if necessary + val outputLine = when { + startSpan == null && endSpan == null -> inputLine.subList(startIndex, endIndex) + else -> buildList { + if (startSpan != null) add(startSpan) + addAll(inputLine.subList(startIndex, endIndex)) + if (endSpan != null) add(endSpan) + } + } + + val remainingWidth = newWidth - width + + if (remainingWidth == 0) { + // The line is exactly the right width + return outputLine + } + + val beginStyle = outputLine.firstOrNull()?.style ?: line.endStyle + val endStyle = line.endStyle + + // The line was too short, add spaces according to the alignment + return when (textAlign) { + LEFT -> outputLine + Span.space(remainingWidth, endStyle) + NONE -> outputLine + Span.space(remainingWidth) // Spaces aren't styled in this alignment + RIGHT -> listOf(Span.space(remainingWidth, beginStyle)) + outputLine + CENTER, JUSTIFY -> { + val l = Span.space(remainingWidth / 2, beginStyle) + val r = Span.space(remainingWidth / 2 + remainingWidth % 2, endStyle) + buildList(outputLine.size + 2) { add(l); addAll(outputLine); add(r) } } } - return Lines(lines) } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Span.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Span.kt index 7d88674ec..7234541f5 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Span.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Span.kt @@ -34,12 +34,11 @@ class Span private constructor(val text: String, val style: TextStyle = DEFAULT_ fun space(width: Int = 1, style: TextStyle = DEFAULT_STYLE): Span { return Span(" ".repeat(width), style) } - - internal fun raw(text: String): Span = Span(text, DEFAULT_STYLE) } internal val cellWidth: Int by lazy(PUBLICATION) { stringCellWidth(text) } internal fun take(n: Int): Span = Span(text.take(n), style) + internal fun drop(n: Int): Span = Span(text.drop(n), style) internal fun isWhitespace(): Boolean = text[0].isWhitespace() internal fun isTab(): Boolean = text[0] == '\t' diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt index 867ba963f..f62121f92 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextColors.kt @@ -17,7 +17,7 @@ enum class AnsiLevel { NONE, ANSI16, ANSI256, TRUECOLOR } * These styles are *not* automatically downsampled. You should print the styled strings with * [Terminal.println] to do so. * - * ## Example + * ### Example * * ``` * import com.github.ajalt.mordant.rendering.TextStyles.* @@ -104,7 +104,7 @@ enum class TextStyles(val style: TextStyle) { * These styles are *not* automatically downsampled. You should print the styled strings with * [Terminal.println] to do so. * - * ## Example + * ### Example * * ``` * import com.github.ajalt.mordant.rendering.TextColors.* diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextStyle.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextStyle.kt index 4be399bcc..1ce2cf428 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextStyle.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/TextStyle.kt @@ -48,6 +48,9 @@ interface TextStyle { */ infix fun on(bg: TextStyle): TextStyle + /** + * Apply this style to [text]. + */ operator fun invoke(text: String): String = invokeStyle(text) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/Table.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/Table.kt index 13ba99d81..17ec89c16 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/Table.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/Table.kt @@ -43,6 +43,7 @@ internal sealed class Cell { val style: TextStyle?, val textAlign: TextAlign, val verticalAlign: VerticalAlign, + val paddingWidth: Int, ) : Cell() { init { require(rowSpan > 0) { "rowSpan must be greater than 0" } @@ -75,6 +76,7 @@ internal class TableImpl( val footerRowCount: Int, val columnWidths: List, val tableBorders: Borders?, + val addPaddingWidthToFixedWidth: Boolean, ) : Table() { init { require(rows.isNotEmpty()) { "Table cannot be empty" } @@ -88,7 +90,7 @@ internal class TableImpl( when { y == 0 && tableBorders != null -> tableBorders.top y == rows.size && tableBorders != null -> tableBorders.bottom - else -> (0 until columnCount).any { x -> + else -> (0.. getCell(x, y).t || getCell(x, y - 1).b } } @@ -134,8 +136,13 @@ internal class TableImpl( } private fun measureColumn(x: Int, t: Terminal, width: Int): WidthRange { - columnWidths[x].width?.let { - return WidthRange(it, it) + columnWidths[x].width?.let { // Fixed width + val paddingWidth = if (!addPaddingWidthToFixedWidth) 0 else { + rows.maxOfOrNull { row -> + (row.getOrNull(x) as? Cell.Content)?.paddingWidth ?: 0 + } ?: 0 + } + return WidthRange(it + paddingWidth, it + paddingWidth) } val range = rows.maxWidthRange { row -> when (val cell = row.getOrNull(x)) { @@ -243,7 +250,7 @@ private class TableRenderer( private val renderedRows = rows.map { r -> r.mapIndexed { x, it -> if (it is Cell.Content) { - val w = (x until x + it.columnSpan).sumOf { columnWidths[it] } + val w = (x.. { - val cellWidth = ((x until x + cell.columnSpan).sumOf { columnWidths[it] } + - ((x + 1) until (x + cell.columnSpan)).count { columnBorders[it + 1] } + val cellWidth = ((x.. "Auto" + isExpand -> "Expand($expandWeight)" + isFixed -> "Fixed($width)" + else -> "Custom(width=$width, expandWeight=$expandWeight, priority=$priority)" + } + } } /** The column will fit to the size of its content */ @@ -150,8 +160,23 @@ interface ColumnBuilder : CellStyleBuilder { var width: ColumnWidth } +interface ColumnHolderBuilder { + /** + * If false, (the default) [padding][CellStyleBuilder.padding] in + * [fixed width][ColumnWidth.Fixed] columns will reduce the content width so + * that the total width is always exactly the specified width. + * + * If true, padding will be added to the specified width so padding never reduces the content + * width. + */ + var addPaddingWidthToFixedWidth: Boolean + + /** Configure a single column, which the first column at index 0. */ + fun column(i: Int, init: ColumnBuilder.() -> Unit) +} + @MordantDsl -interface TableBuilder : CellStyleBuilder { +interface TableBuilder : CellStyleBuilder, ColumnHolderBuilder { /** The characters to use to draw cell edges */ var borderType: BorderType @@ -178,9 +203,6 @@ interface TableBuilder : CellStyleBuilder { /** Add [text] as a caption to the bottom of this table. */ fun captionBottom(text: String, align: TextAlign = TextAlign.CENTER) - /** Configure a single column, which the first column at index 0. */ - fun column(i: Int, init: ColumnBuilder.() -> Unit) - /** Configure the header section. */ fun header(init: SectionBuilder.() -> Unit) @@ -217,10 +239,7 @@ interface SectionBuilder : CellStyleBuilder, RowHolderBuilder { } @MordantDsl -interface GridBuilder : CellStyleBuilder, RowHolderBuilder { - /** Configure a single column, with the first column at index 0. */ - fun column(i: Int, init: ColumnBuilder.() -> Unit) -} +interface GridBuilder : CellStyleBuilder, RowHolderBuilder, ColumnHolderBuilder @MordantDsl interface RowBuilder : CellStyleBuilder { @@ -263,11 +282,9 @@ interface LinearLayoutBuilder : CellStyleBuilderBase { } @MordantDsl -interface HorizontalLayoutBuilder : LinearLayoutBuilder { +interface HorizontalLayoutBuilder : LinearLayoutBuilder, ColumnHolderBuilder { + /** Vertical alignment of cell contents */ var verticalAlign: VerticalAlign? - - /** Configure a single column, with the first column at index 0. */ - fun column(i: Int, init: ColumnBuilder.() -> Unit) } @MordantDsl @@ -386,3 +403,19 @@ internal fun ColumnWidth?.toCustom(): ColumnWidth.Custom { is ColumnWidth.Custom -> this } } + +internal val ColumnWidth.isAuto: Boolean + get() { + return this is ColumnWidth.Auto || + this is ColumnWidth.Custom && this.width == null && this.expandWeight == null + } + +internal val ColumnWidth.isExpand: Boolean + get() { + return this is ColumnWidth.Expand || this is ColumnWidth.Custom && this.expandWeight != null + } + +internal val ColumnWidth.isFixed: Boolean + get() { + return this is ColumnWidth.Fixed || this is ColumnWidth.Custom && this.width != null + } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDslInstances.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDslInstances.kt index bc0f6efde..2de2baf6f 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDslInstances.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDslInstances.kt @@ -33,6 +33,7 @@ internal class TableBuilderInstance : TableBuilder, CellStyleBuilder by CellStyl override var borderType: BorderType = BorderType.SQUARE override var borderStyle: TextStyle = DEFAULT_STYLE override var tableBorders: Borders? = null + override var addPaddingWidthToFixedWidth: Boolean = true val columns = mutableMapOf() val headerSection = SectionBuilderInstance() @@ -115,9 +116,10 @@ internal class SectionBuilderInstance : SectionBuilder, @MordantDsl internal class GridBuilderInstance( private val tableBuilder: TableBuilderInstance = TableBuilderInstance(), -) : GridBuilder, CellStyleBuilder by tableBuilder { - private val section = tableBuilder.bodySection - +) : GridBuilder, + CellStyleBuilder by tableBuilder, + ColumnHolderBuilder by tableBuilder, + RowHolderBuilder by tableBuilder.bodySection { init { tableBuilder.cellBorders = Borders.LEFT_RIGHT tableBuilder.tableBorders = Borders.NONE @@ -125,24 +127,6 @@ internal class GridBuilderInstance( tableBuilder.padding = Padding(0) } - override fun column(i: Int, init: ColumnBuilder.() -> Unit) = tableBuilder.column(i, init) - - override fun rowStyles(style1: TextStyle, style2: TextStyle, vararg styles: TextStyle) { - section.rowStyles(style1, style2, *styles) - } - - override fun rowFrom(cells: Iterable, init: RowBuilder.() -> Unit) { - section.rowFrom(cells, init) - } - - override fun row(vararg cells: Any?, init: RowBuilder.() -> Unit) { - section.row(*cells, init = init) - } - - override fun row(init: RowBuilder.() -> Unit) { - section.row(init) - } - fun build(): Widget { return TableLayout(tableBuilder).buildTable() } @@ -151,8 +135,10 @@ internal class GridBuilderInstance( @MordantDsl internal class HorizontalLayoutBuilderInstance( private val tableBuilder: TableBuilderInstance = TableBuilderInstance(), -) : HorizontalLayoutBuilder, CellStyleBuilderBase by tableBuilder { - private val row = RowBuilderInstance(mutableListOf()) + private val row: RowBuilderInstance = RowBuilderInstance(mutableListOf()), +) : HorizontalLayoutBuilder, + CellStyleBuilderBase by tableBuilder, + ColumnHolderBuilder by tableBuilder { override var spacing: Int = 1 override var verticalAlign: VerticalAlign? = null @@ -173,20 +159,12 @@ internal class HorizontalLayoutBuilderInstance( row.cell(content, init) } - override fun column(i: Int, init: ColumnBuilder.() -> Unit) = tableBuilder.column(i, init) - fun build(): Widget { - if (spacing > 0) { - for ((i, cell) in row.cells.withIndex()) { - if (i == 0) continue - cell.padding = cell.padding?.let { it.copy(left = it.left + spacing) } ?: Padding { - left = spacing - } - } - } + tableBuilder.addPaddingWidthToFixedWidth = true tableBuilder.verticalAlign = verticalAlign tableBuilder.cellBorders = Borders.NONE - tableBuilder.padding = Padding(0) + tableBuilder.padding = Padding { left = spacing } + row.cells.getOrNull(0)?.padding(0) tableBuilder.bodySection.rows += row return TableLayout(tableBuilder).buildTable() } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt index e02cd81de..bd22bff49 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt @@ -35,7 +35,8 @@ internal class TableLayout(private val table: TableBuilderInstance) { borderBottom = b.bottom, style = null, textAlign = TextAlign.LEFT, - verticalAlign = VerticalAlign.TOP + verticalAlign = VerticalAlign.TOP, + paddingWidth = 0, ) listOf(listOf(cell)) } @@ -51,7 +52,8 @@ internal class TableLayout(private val table: TableBuilderInstance) { headerRowCount = header.size, footerRowCount = footer.size, columnWidths = columnWidths, - tableBorders = table.tableBorders + tableBorders = table.tableBorders, + addPaddingWidthToFixedWidth = table.addPaddingWidthToFixedWidth, ) } @@ -87,7 +89,7 @@ internal class TableLayout(private val table: TableBuilderInstance) { val row = section.rows[startingY] // The W3 standard says that spans are truncated rather than increasing the size of the table - val maxRowSize = (startingY until startingY + cell.rowSpan) + val maxRowSize = (startingY..() val spacingLine = when (textAlign) { - TextAlign.NONE -> EMPTY_LINE + NONE -> EMPTY_LINE else -> Line(listOf(Span.space(renderWidth)), DEFAULT_STYLE) } for ((i, cell) in cells.withIndex()) { @@ -71,6 +72,7 @@ internal class VerticalLayout private constructor( rendered = when { w.expandWeight != null -> rendered.setSize(width, textAlign = cell.textAlign) w.width != null -> rendered.setSize(w.width, textAlign = cell.textAlign) + cell.textAlign != NONE -> rendered.setSize(renderWidth, textAlign = cell.textAlign) else -> rendered } // Cells always take up a line, even if empty diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt index 9e086518b..b038491ab 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt @@ -29,7 +29,8 @@ fun TerminalRecorder.outputAsHtml( append("padding: 0.5em 1em;") append("filter: drop-shadow(0.5em 0.5em 0.5em black);") append("background-color: ${backgroundColor.formatCssString()};") - append("""">\n
""") + append("\">") + append("""
""") for (color in listOf(SRGB("#ff5f56"), SRGB("#ffbd2e"), SRGB("#27c93f"))) { append("""⏺ """) } 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 a65c7cd40..a916163e2 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 @@ -1,9 +1,6 @@ package com.github.ajalt.mordant.terminal -import com.github.ajalt.mordant.internal.makePrintingTerminalCursor -import com.github.ajalt.mordant.internal.renderLinesAnsi -import com.github.ajalt.mordant.internal.sendInterceptedPrintRequest -import com.github.ajalt.mordant.internal.synchronizeJvm +import com.github.ajalt.mordant.internal.* import com.github.ajalt.mordant.rendering.* import com.github.ajalt.mordant.table.table import com.github.ajalt.mordant.widgets.HorizontalRule @@ -24,8 +21,7 @@ class Terminal private constructor( val theme: Theme, val tabWidth: Int, private val terminalInterface: TerminalInterface, - private val interceptors: MutableList, - private val lock: Any, + private val interceptors: MppAtomicRef>, ) { /** * @param ansiLevel The level of color support to use, or `null` to detect the level of the current terminal @@ -65,7 +61,7 @@ class Terminal private constructor( theme: Theme, tabWidth: Int, terminalInterface: TerminalInterface, - ) : this(theme, tabWidth, terminalInterface, mutableListOf(), Any()) + ) : this(theme, tabWidth, terminalInterface, MppAtomicRef(emptyList())) /** * @param theme The theme to use for widgets and styles like [success] @@ -74,14 +70,14 @@ class Terminal private constructor( constructor( theme: Theme, terminalInterface: TerminalInterface, - ) : this(theme, 8, terminalInterface, mutableListOf(), Any()) + ) : this(theme, 8, terminalInterface, MppAtomicRef(emptyList())) /** * @param terminalInterface The [TerminalInterface] to use to read and write */ constructor( terminalInterface: TerminalInterface, - ) : this(Theme.Default, 8, terminalInterface, mutableListOf(), Any()) + ) : this(Theme.Default, 8, terminalInterface, MppAtomicRef(emptyList())) /** * The terminal capabilities that were detected or set in the constructor. @@ -366,15 +362,11 @@ class Terminal private constructor( } internal fun addInterceptor(interceptor: TerminalInterceptor) { - synchronizeJvm(lock) { - interceptors += interceptor - } + interceptors.update { this + interceptor } } internal fun removeInterceptor(interceptor: TerminalInterceptor) { - synchronizeJvm(lock) { - interceptors.remove(interceptor) - } + interceptors.update { filter { it != interceptor } } } private fun rawPrintln(message: String, stderr: Boolean) { @@ -382,8 +374,7 @@ class Terminal private constructor( } private fun sendPrintRequest(request: PrintRequest) { - synchronizeJvm(lock) { - sendInterceptedPrintRequest(request, terminalInterface, interceptors) - } + if (FAST_ISATTY) info.updateTerminalSize() + sendInterceptedPrintRequest(request, terminalInterface, interceptors.value) } } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt index 04e1c45c7..5f41e8604 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt @@ -26,17 +26,17 @@ internal object TerminalDetection { ansiHyperLinks = ansiHyperLinks, outputInteractive = outputInteractive, inputInteractive = inputInteractive, - crClearsLine = ij + crClearsLine = ij // TODO(3.0): rename this to supportsAnsiCursor ) } - /** Returns a pair of `[width, height]`, or `null` if the size can't be detected */ - fun detectSize(): Pair? = getTerminalSize() + /** Returns the size, or `null` if the size can't be detected */ + fun detectSize(): Size? = getTerminalSize() - private fun detectInitialSize(): Pair { - return getTerminalSize() ?: Pair( - (getEnv("COLUMNS")?.toIntOrNull() ?: 79), - (getEnv("LINES")?.toIntOrNull() ?: 24) + private fun detectInitialSize(): Size { + return getTerminalSize() ?: Size( + width = (getEnv("COLUMNS")?.toIntOrNull() ?: 79), + height = (getEnv("LINES")?.toIntOrNull() ?: 24) ) } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Panel.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Panel.kt index 9a858fe7c..fb2e98ee6 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Panel.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Panel.kt @@ -15,7 +15,7 @@ import com.github.ajalt.mordant.terminal.Terminal /** * A box drawn around another widget, with optional top and bottom titles. * - * ## Example + * ### Example * * ``` * terminal.print(Panel( 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 70eb24470..ccc970626 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 @@ -154,7 +154,7 @@ class ProgressBar private constructor( return makeLine(listOfNotNull(segmentText(completeChar[t], w, finishedStyle[t]))) } - val sep = if (completedLength in 1 until w) segmentText( + val sep = if (completedLength in 1..= 0) { "completed cannot be negative" } - require(total == null || total >= 0) { "total cannot be negative" } - require(elapsedSeconds >= 0) { "elapsedSeconds cannot be negative" } - require(completedPerSecond >= 0) { "completedPerSecond cannot be negative" } - } - - val indeterminate: Boolean get() = total == null -} - -internal interface ProgressCell { - enum class AnimationRate { - STATIC, ANIMATION, TEXT - } - - val columnWidth: ColumnWidth - val animationRate: AnimationRate - - fun ProgressState.makeWidget(): Widget -} - - -internal class TextProgressCell(private val text: Text) : ProgressCell { - override val animationRate: AnimationRate get() = AnimationRate.STATIC - override val columnWidth: ColumnWidth get() = ColumnWidth.Auto - override fun ProgressState.makeWidget(): Widget = text -} - -internal class SpinnerProgressCell( - private val frames: List, - 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) // " 100%" - override fun ProgressState.makeWidget(): Widget { - val percent = when { - total == null || total <= 0 -> 0 - else -> (100.0 * completed / total).toInt() - } - return Text("$percent%") - } -} - -internal class CompletedProgressCell( - private val suffix: String, - private val includeTotal: Boolean, - private val style: TextStyle, -) : ProgressCell { - override val animationRate: AnimationRate get() = AnimationRate.TEXT - override val columnWidth: ColumnWidth - // " 100.0M" - // " 100.0/200.0M" - get() = ColumnWidth.Fixed((if (includeTotal) 12 else 6) + suffix.length) - - override fun ProgressState.makeWidget(): Widget { - val complete = completed.toDouble() - val (nums, unit) = formatMultipleWithSiSuffixes(1, complete, total?.toDouble() ?: 0.0) - - val t = nums[0] + when { - includeTotal && total != null -> "/${nums[1]}$unit" - includeTotal && total == null -> "/---.-" - else -> "" - } + suffix - return Text(style(t), whitespace = Whitespace.PRE) - } -} - -internal class SpeedProgressCell( - private val suffix: String, - private val style: TextStyle, -) : ProgressCell { - override val animationRate: AnimationRate get() = AnimationRate.TEXT - override val columnWidth: ColumnWidth get() = ColumnWidth.Fixed(6 + suffix.length) // " 100.0M" - - override fun ProgressState.makeWidget(): Widget { - val t = when { - indeterminate || completedPerSecond <= 0 -> "---.-" - else -> completedPerSecond.formatWithSiSuffix(1) - } - return Text(style(t + suffix), whitespace = Whitespace.PRE) - } -} - -internal class EtaProgressCell( - private val prefix: String, - private val style: TextStyle, -) : ProgressCell { - override val animationRate: AnimationRate get() = AnimationRate.TEXT - override val columnWidth: ColumnWidth get() = ColumnWidth.Fixed(7 + prefix.length) // " 0:00:02" - - override fun ProgressState.makeWidget(): Widget { - val eta = if (total == null) 0.0 else (total - completed) / completedPerSecond - val maxEta = 35_999 // 9:59:59 - if (indeterminate || eta < 0 || completedPerSecond == 0.0 || eta > maxEta) { - return text("$prefix-:--:--") - } - - val h = (eta / (60 * 60)).toInt() - val m = (eta / 60 % 60).toInt() - val s = (eta % 60).roundToInt() - - return text("$prefix$h:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}") - } - - private fun text(s: String) = Text(style(s), whitespace = Whitespace.PRE) -} - -internal class BarProgressCell( - val width: Int?, - private val pendingChar: String? = null, - private val separatorChar: String? = null, - private val completeChar: String? = null, - private val pendingStyle: TextStyle? = null, - private val separatorStyle: TextStyle? = null, - private val completeStyle: TextStyle? = null, - private val finishedStyle: TextStyle? = null, - private val indeterminateStyle: TextStyle? = null, - private val pulse: Boolean? = null, -) : ProgressCell { - override val animationRate: AnimationRate get() = AnimationRate.ANIMATION - override val columnWidth: ColumnWidth - get() = width?.let { ColumnWidth.Fixed(it) } ?: ColumnWidth.Expand() - - override fun ProgressState.makeWidget(): Widget { - val period = 2 // this could be configurable - val pulsePosition = ((elapsedSeconds % period) / period) - - return ProgressBar( - total ?: 100, - completed, - indeterminate, - width, - pulsePosition.toFloat(), - pulse, - pendingChar, - separatorChar, - completeChar, - pendingStyle, - separatorStyle, - completeStyle, - finishedStyle, - indeterminateStyle - ) - } -} 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 08dff4733..98acbc957 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 @@ -1,27 +1,37 @@ +@file:Suppress("DEPRECATION") + package com.github.ajalt.mordant.widgets import com.github.ajalt.mordant.internal.DEFAULT_STYLE -import com.github.ajalt.mordant.rendering.TextAlign import com.github.ajalt.mordant.rendering.TextStyle import com.github.ajalt.mordant.rendering.Widget -import com.github.ajalt.mordant.table.ColumnWidth -import com.github.ajalt.mordant.table.horizontalLayout +import com.github.ajalt.mordant.widgets.progress.* +import com.github.ajalt.mordant.widgets.progress.ProgressState.Status.Finished +import com.github.ajalt.mordant.widgets.progress.ProgressState.Status.Running +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit +import kotlin.time.TestTimeSource -open class ProgressBuilder internal constructor() { +@Deprecated("Use progressBarLayout instead") +open class ProgressBuilder internal constructor( + internal val builder: ProgressLayoutBuilder, +) { var padding: Int = 2 /** - * Add fixed text cell to this layout. + * Add a fixed text cell to this layout. */ fun text(text: String) { - cells += TextProgressCell(Text(text)) + builder.text(text) } /** * Add a percentage cell to this layout. */ fun percentage() { - cells += PercentageProgressCell() + builder.percentage() } /** @@ -50,7 +60,7 @@ open class ProgressBuilder internal constructor() { indeterminateStyle: TextStyle? = null, showPulse: Boolean? = null, ) { - cells += BarProgressCell( + builder.progressBar( width, pendingChar, separatorChar, @@ -60,29 +70,33 @@ open class ProgressBuilder internal constructor() { completeStyle, finishedStyle, indeterminateStyle, - showPulse, + if (showPulse == false) ZERO else 2.seconds, ) } /** * Add a cell that displays the current completed count to this layout. */ - fun completed(suffix: String = "", includeTotal: Boolean = true, style: TextStyle = DEFAULT_STYLE) { - cells += CompletedProgressCell(suffix, includeTotal, style) + fun completed( + suffix: String = "", + includeTotal: Boolean = true, + style: TextStyle = DEFAULT_STYLE, + ) { + builder.completed(suffix, includeTotal, style = style) } /** * Add a cell that displays the current speed to this layout. */ fun speed(suffix: String = "it/s", style: TextStyle = DEFAULT_STYLE) { - cells += SpeedProgressCell(suffix, style) + builder.speed(suffix, style) } /** * Add a cell that displays the time remaining to this layout. */ fun timeRemaining(prefix: String = "eta ", style: TextStyle = DEFAULT_STYLE) { - cells += EtaProgressCell(prefix, style) + builder.timeRemaining(prefix, false, style = style) } /** @@ -92,22 +106,16 @@ open class ProgressBuilder internal constructor() { * @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) + builder.spinner(spinner, fps = frameRate) } - - internal val cells = mutableListOf() } /** * A builder for creating an animated progress bar widget. */ +@Deprecated("Use progressBarLayout instead") class ProgressLayout internal constructor( - internal val cells: List, - private val paddingSize: Int, + private val factory: ProgressBarDefinition, ) { fun build( completed: Long, @@ -115,36 +123,35 @@ class ProgressLayout internal constructor( elapsedSeconds: Double = 0.0, completedPerSecond: Double? = null, ): Widget { - val cps = completedPerSecond ?: when { - completed <= 0 || elapsedSeconds <= 0 -> 0.0 - else -> completed.toDouble() / elapsedSeconds + val t = TestTimeSource() + val displayedTime = t.markNow() + t += elapsedSeconds.seconds + val speed = (completedPerSecond ?: calcHz(completed, elapsedSeconds.seconds)) + .takeIf { it > 0 } + val status = when { + total != null && completed >= total -> Finished(displayedTime, displayedTime) + else -> Running(displayedTime) } - val state = ProgressState( - completed = completed, - total = total, - completedPerSecond = cps, - elapsedSeconds = elapsedSeconds, + return factory.build( + total, + completed, + displayedTime, + status, + speed = speed ) - return horizontalLayout { - spacing = paddingSize - align = TextAlign.RIGHT - cellsFrom(cells.map { it.run { state.run { makeWidget() } } }) - cells.forEachIndexed { i, it -> - column(i) { - width = when (val w = it.columnWidth) { - // The fixed width cells don't include padding, so add it here - is ColumnWidth.Fixed -> ColumnWidth.Fixed(w.width + paddingSize) - else -> w - } - } - } - } } } +private fun calcHz(completed: Long, elapsed: Duration): Double = when { + completed <= 0 || elapsed <= ZERO -> 0.0 + else -> completed / elapsed.toDouble(DurationUnit.SECONDS) +} + /** * Build a [ProgressLayout] */ +@Deprecated("Use progressBarLayout instead") fun progressLayout(init: ProgressBuilder.() -> Unit): ProgressLayout { - return ProgressBuilder().apply(init).build() + val builder = ProgressBuilder(ProgressLayoutBuilder()).apply(init) + return ProgressLayout(builder.builder.build(builder.padding, true)) } 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 index 3b77a0a63..857e2c04f 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt @@ -16,13 +16,12 @@ import com.github.ajalt.mordant.terminal.Terminal * * 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, + private val frames: List, private val duration: Int = 1, initial: Int = 0, ) : Widget { @@ -74,7 +73,8 @@ class Spinner( return _tick.getAndIncrement() + 1 } - private val currentFrame get() = frames[(tick / duration) % frames.size] + /** The current frame */ + val currentFrame: Widget 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/commonMain/kotlin/com/github/ajalt/mordant/widgets/Text.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Text.kt index 90f38aae2..6c4ba199a 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Text.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Text.kt @@ -94,7 +94,7 @@ class Text internal constructor( } // Trim trailing whitespace pieces. Continue rather than break in case there is a trailing NEL - if ((whitespace.trimEol || align == JUSTIFY) && lastNonWhitespace in 0 until i) { + if ((whitespace.trimEol || align == JUSTIFY) && lastNonWhitespace in 0..( + definition: ProgressBarDefinition, + private val timeSource: TimeSource.WithComparableMarks, +) : ProgressBarDefinition { + private val cache = MppAtomicRef>>(emptyMap()) + override val cells: List> = + definition.cells.mapIndexed { i, it -> makeCell(i, it) } + override val spacing: Int = definition.spacing + override val alignColumns: Boolean = definition.alignColumns + + /** + * Invalidate the cache for this definition. + */ + fun invalidateCache() { + cache.getAndSet(emptyMap()) + } + + /** + * The refresh rate, Hz, that will satisfy the [fps][ProgressBarCell.fps] of + * all this progress bar's cells. + */ + val fps: Int = definition.cells.maxOfOrNull { it.fps } ?: 0 + + // Wrap the cell builder in a block that caches the widget + private fun makeCell( + i: Int, + cell: ProgressBarCell, + ): ProgressBarCell { + return ProgressBarCell(cell.columnWidth, cell.fps, cell.align, cell.verticalAlign) { + val (_, new) = cache.update { + when { + isCacheValid(cell, this[i]?.first) -> this + else -> { + val content = cell.content(this@ProgressBarCell) + this + (i to (timeSource.markNow() to content)) + } + } + } + + new[i]?.second ?: cell.content(this) + } + } + + private fun isCacheValid( + cell: ProgressBarCell, lastFrameTime: ComparableTimeMark?, + ): Boolean { + if (lastFrameTime == null) return false + val timeSinceLastFrame = lastFrameTime.elapsedNow() + // if fps is 0 this will be Infinity, so it will be cached forever + val maxCacheRetentionDuration = (1.0 / cell.fps).seconds + return timeSinceLastFrame < maxCacheRetentionDuration + } +} + +/** + * Cache this progress bar definition so that each cell only updates as often as its + * [fps][ProgressBarCell.fps]. + */ +fun ProgressBarDefinition.cache( + timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, +): CachedProgressBarDefinition { + return when (this) { + is CachedProgressBarDefinition -> this + else -> CachedProgressBarDefinition(this, timeSource) + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker.kt new file mode 100644 index 000000000..8f910fdbc --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressBarWidgetMaker.kt @@ -0,0 +1,161 @@ +package com.github.ajalt.mordant.widgets.progress + +import com.github.ajalt.mordant.rendering.TextAlign +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.table.* +import com.github.ajalt.mordant.widgets.EmptyWidget +import com.github.ajalt.mordant.widgets.Padding +import kotlin.math.max + +data class ProgressBarMakerRow( + val definition: ProgressBarDefinition, + val state: ProgressState, +) + +/** + * Instances of this interface creates widgets for progress bars. + * + * The default implementation is [MultiProgressBarWidgetMaker]. + */ +interface ProgressBarWidgetMaker { + /** + * Build a progress widget with the given [rows]. + */ + fun build(rows: List>): Widget +} + +/** + * The default implementation of [ProgressBarWidgetMaker]. + * + * It uses a [grid] or [verticalLayout] to render the progress bars. + */ +object MultiProgressBarWidgetMaker : ProgressBarWidgetMaker { + override fun build(rows: List>): Widget { + return when { + rows.isEmpty() -> EmptyWidget + rows.size == 1 -> makeHorizontalLayout(rows[0]) + rows.all { it.definition.alignColumns } -> makeTable(rows) + else -> makeVerticalLayout(rows) + } + } + + /** + * Build the widgets for each cell in the progress bar. + * + * This can be used if you want to manually include the individual cells in a layout like + * a table. + * + * @return A list of rows, where each row is a list of widgets for the cells in that row. + */ + fun buildCells(rows: List>): List> { + return rows.map { row -> renderRow(row).map { it.second } } + } + + private fun makeVerticalLayout(rows: List>): Widget { + return verticalLayout { + align = TextAlign.LEFT // LEFT instead of NONE so that the widget isn't jagged + var alignStart = -1 + for ((i, row) in rows.withIndex()) { + // render contiguous aligned rows as a table. + // we can't just throw the whole thing in one table, since the unaligned rows will + // mess up the column widths for the aligned rows since spanned columns have their + // width divided evenly between the columns they span + if (!row.definition.alignColumns) { + if (alignStart >= 0) { + cell(makeTable(rows.subList(alignStart, i))) + alignStart = -1 + } + cell(makeHorizontalLayout(row)) + } else if (alignStart < 0) { + alignStart = i + } + } + if (alignStart >= 0) { + cell(makeTable(rows.subList(alignStart, rows.size))) + } + } + } + + private fun makeHorizontalLayout( + row: ProgressBarMakerRow, + ): Widget = horizontalLayout { + spacing = row.definition.spacing + for ((i, barCell) in row.definition.cells.withIndex()) { + cell(barCell.content(row.state)) + column(i) { + width = barCell.columnWidth + align = barCell.align + verticalAlign = barCell.verticalAlign + } + } + } + + private fun makeTable(rows: List>): Widget { + return grid { + addPaddingWidthToFixedWidth = true + cellBorders = Borders.NONE + val columnCount = rows.maxOf { (d, _) -> if (d.alignColumns) d.cells.size else 0 } + for (i in 0.. { + ColumnWidth.Expand(nullMax(w1.expandWeight, w2.expandWeight) ?: 1f) + } + // If we had a MinWidth type, we'd use it here instead of Auto + (j > 0 && w1.isAuto) || w2.isAuto -> ColumnWidth.Auto + else -> ColumnWidth.Fixed(nullMax(w1.width, w2.width)!!) + } + } + } + } + for (r in rows) { + row { + if (r.definition.alignColumns) { + for ((i, c) in renderRow(r).withIndex()) { + cell(c.second) { + align = c.first.align + verticalAlign = c.first.verticalAlign + padding = Padding { left = if (i == 0) 0 else r.definition.spacing } + } + } + } else { + cell(makeHorizontalLayout(r)) { + columnSpan = Int.MAX_VALUE + } + } + } + } + } + } + + private fun renderRow(row: ProgressBarMakerRow): List, Widget>> { + return row.definition.cells.map { cell -> cell to cell.content(row.state) } + } +} + +/** + * Build a progress widget with the given [rows]. + */ +fun ProgressBarWidgetMaker.build(vararg rows: ProgressBarMakerRow<*>): Widget { + return build(rows.asList()) +} + +/** + * Build a progress widget with the given [rows]. + */ +fun ProgressBarWidgetMaker.build( + vararg rows: Pair, ProgressState>, +): Widget { + return build(rows.map { ProgressBarMakerRow(it.first, it.second) }) +} + +private fun nullMax(a: Int?, b: Int?): Int? = + if (a == null) b else if (b == null) a else max(a, b) + +private fun nullMax(a: Float?, b: Float?): Float? = + if (a == null) b else if (b == null) a else max(a, b) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressLayoutCells.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressLayoutCells.kt new file mode 100644 index 000000000..2c2134dfe --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressLayoutCells.kt @@ -0,0 +1,336 @@ +package com.github.ajalt.mordant.widgets.progress + +import com.github.ajalt.mordant.internal.* +import com.github.ajalt.mordant.rendering.TextAlign +import com.github.ajalt.mordant.rendering.TextStyle +import com.github.ajalt.mordant.rendering.VerticalAlign +import com.github.ajalt.mordant.rendering.Whitespace +import com.github.ajalt.mordant.table.ColumnWidth +import com.github.ajalt.mordant.widgets.ProgressBar +import com.github.ajalt.mordant.widgets.Spinner +import com.github.ajalt.mordant.widgets.Text +import com.github.ajalt.mordant.widgets.Viewport +import kotlin.math.max +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +/** + * Add a fixed text cell to this layout. + * + * The cell is always the same size as the [content]. For a fixed-width text cell, use [marquee]. + * + * @param content The text to display in this cell. + * @param align The text alignment for this cell. Cells are right-aligned by default. + */ +fun ProgressLayoutScope<*>.text( + content: String, + align: TextAlign = this.align, + verticalAlign: VerticalAlign = this.verticalAlign, +) { + cell(fps = 0, align = align, verticalAlign = verticalAlign) { Text(content) } +} + +/** + * Add a dynamic text cell to this layout. + * + * The [content] lambda will be called with the current progress state as its receiver. + * + * The cell is always the same size as the [content]. For a fixed-width text cell, use [marquee]. + * + * ### Example + * ``` + * text { context.toString() } + * ``` + * + * @param align The text alignment for this cell. Cells are right-aligned by default. + * @param content A lambda returning the text to display in this cell. + */ +fun ProgressLayoutScope.text( + align: TextAlign = this.align, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = textFps, + content: ProgressState.() -> String, +) { + cell(fps = fps, align = align, verticalAlign = verticalAlign) { Text(content()) } +} + +/** + * Add a fixed width text cell that scrolls its contents horizontally so that long text can be + * displayed in a fixed width. + * + * @param width The width of the cell in characters. + * @param fps The number of times per second to update the displayed text. + * @param align The text alignment for this cell when [scrollWhenContentFits] is `false` and the + * [content] fits in the [width]. + * @param scrollWhenContentFits If `true`, the text will always scroll, even if it fits in the + * [width]. + * @param content The text to display in this cell. + */ +fun ProgressLayoutScope.marquee( + width: Int, + fps: Int = 3, + align: TextAlign = this.align, + verticalAlign: VerticalAlign = this.verticalAlign, + scrollWhenContentFits: Boolean = false, + content: ProgressState.() -> String, +) { + require(width > 0) { "width must be greater than zero" } + cell(ColumnWidth.Fixed(width), fps, align, verticalAlign) { + val text = content() + val cellWidth = parseText(text, DEFAULT_STYLE).width + when { + !scrollWhenContentFits && cellWidth <= width -> Text(text) + else -> { + val period = cellWidth + width + val scrollRight = if (isFinished) 0 else frameCount(fps) % period - width + Viewport(Text(text), width, scrollRight = scrollRight) + } + } + } +} + +/** + * Add a fixed width text cell that scrolls its contents horizontally so that long text can be + * displayed in a fixed width. + * + * @param content The text to display in this cell. + * @param width The width of the cell in characters. + * @param fps The number of times per second to update the displayed text. + * @param align The text alignment for this cell when [scrollWhenContentFits] is `false` and the + * [content] fits in the [width]. + * @param scrollWhenContentFits If `true`, the text will always scroll, even if it fits in the + * [width]. + */ +fun ProgressLayoutScope<*>.marquee( + content: String, + width: Int, + fps: Int = 3, + align: TextAlign = this.align, + verticalAlign: VerticalAlign = this.verticalAlign, + scrollWhenContentFits: Boolean = false, +) = marquee(width, fps, align, verticalAlign, scrollWhenContentFits) { content } + +/** + * Add a cell that displays the current completed count to this layout. + * + * @param suffix A string to append to the end of the displayed count, such as "B" if you are tracking bytes. Empty by default. + * @param includeTotal If true, the total count will be displayed after the completed count, separated by a slash. True by default. + * @param precision The number of decimal places to display. 1 by default. + * @param style The style to use for the displayed count. + * @param fps The number of times per second to update the displayed count. Uses the + * [text fps][ProgressLayoutScope.textFps] by default. + */ +fun ProgressLayoutScope<*>.completed( + suffix: String = "", + includeTotal: Boolean = true, + precision: Int = 1, + style: TextStyle = DEFAULT_STYLE, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = textFps, +) = cell( + // "1000" + // "100.0M" + // "100.0/200.0M" + width = ColumnWidth.Fixed( + (3 + precision + if (precision > 0) 1 else 0).let { + (if (includeTotal) it * 2 + 1 else it) + suffix.length + 1 + } + ), + fps = fps, + verticalAlign = verticalAlign, +) { + val complete = completed.toDouble() + val total = total?.toDouble() + val (nums, unit) = formatMultipleWithSiSuffixes(precision, true, complete, total ?: 0.0) + + val formattedTotal = when { + includeTotal && total != null && total >= 0 -> "/${nums[1]}$unit" + includeTotal && (total == null || total < 0) -> "/---.-" + else -> "" + } + Text(style(nums[0] + formattedTotal + suffix), whitespace = Whitespace.PRE) +} + +/** + * Add a cell that displays the current speed to this layout. + * + * @param suffix A string to append to the end of the displayed speed, such as "B/s" if you are tracking bytes. "/s" by default. + * @param style The style to use for the displayed speed. + * @param fps The number of times per second to update the displayed speed. Uses the + * [text fps][ProgressLayoutScope.textFps] by default. + */ +fun ProgressLayoutScope<*>.speed( + suffix: String = "/s", + style: TextStyle = DEFAULT_STYLE, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = textFps, +) = cell( + ColumnWidth.Fixed(6 + suffix.length), // " 100.0M" + fps = fps, + verticalAlign = verticalAlign, +) { + val t = when { + speed == null || speed < 0 -> "---.-" + else -> speed.formatWithSiSuffix(1) + } + Text(style(t + suffix), whitespace = Whitespace.PRE) +} + +/** + * Add a cell that displays the current percentage to this layout. + * + * @param fps The number of times per second to update the displayed percentage. Uses the + * [text fps][ProgressLayoutScope.textFps] by default. + */ +fun ProgressLayoutScope<*>.percentage( + fps: Int = textFps, + style: TextStyle = DEFAULT_STYLE, + verticalAlign: VerticalAlign = this.verticalAlign, +) = cell( + ColumnWidth.Fixed(4), // " 100%" + fps = fps, + verticalAlign = verticalAlign, +) { + val percent = when { + total == null || total <= 0 -> 0 + else -> (100.0 * completed / total).toInt() + } + Text(style("$percent%")) +} + +/** + * Add a cell that displays the time remaining to this layout. + * + * @param prefix A string to prepend to the displayed time, such as `"eta "` or `"time left: "`. `"eta "` by default. + * @param compact If `true`, the displayed time will be formatted as `"MM:SS"` if time remaining is less than an hour. `false` by default. + * @param elapsedWhenFinished If `true`, the elapsed time will be displayed when the task is finished. `false` by default. + * @param elapsedPrefix A string to prepend to the displayed time when [elapsedWhenFinished] is `true`. `" in "` by default. + * @param style The style to use for the displayed time. + * @param fps The number of times per second to update the displayed time. Uses the + * [text fps][ProgressLayoutScope.textFps] by default. + */ +fun ProgressLayoutScope<*>.timeRemaining( + prefix: String = "eta ", + compact: Boolean = false, + elapsedWhenFinished: Boolean = false, + elapsedPrefix: String = " in ", + style: TextStyle = DEFAULT_STYLE, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = textFps, +) = cell( + ColumnWidth.Fixed(7 + max(prefix.length, elapsedPrefix.length)), // "0:00:02" + fps = fps, + verticalAlign = verticalAlign, +) { + val eta = calculateTimeRemaining(elapsedWhenFinished) + val p = if (isFinished && elapsedWhenFinished) elapsedPrefix else prefix + val maxEta = 35_999.seconds // 9:59:59 + val duration = if (eta != null && eta <= maxEta) eta else null + Text(style(p + renderDuration(duration, compact)), whitespace = Whitespace.PRE) +} + +/** + * Add a cell that displays the elapsed time to this layout. + * + * @param compact If `true`, the displayed time will be formatted as "MM:SS" if time remaining is less than an hour. `false` by default. + * @param style The style to use for the displayed time. + * @param fps The number of times per second to update the displayed time. Uses the + * [text fps][ProgressLayoutScope.textFps] by default. + */ +fun ProgressLayoutScope<*>.timeElapsed( + compact: Boolean = false, + style: TextStyle = DEFAULT_STYLE, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = textFps, +) = text(fps = fps, verticalAlign = verticalAlign) { + style(renderDuration(calculateTimeElapsed(), compact)) +} + +/** + * Add a [Spinner] to this layout. + * + * ### Example + * ``` + * spinner(Spinner.Dots()) + * ``` + * + * @param spinner The spinner to display + * @param fps The number of times per second to advance the spinner's displayed frame. 8 by default. + */ +fun ProgressLayoutScope<*>.spinner( + spinner: Spinner, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = 8, +) = cell(fps = fps, verticalAlign = verticalAlign) { + spinner.tick = frameCount(fps) + if (isRunning) spinner else BlankWidgetWrapper(spinner) +} + +/** + * Add a progress bar cell to this layout. + * + * @param width The width in characters for this widget, or `null` to expand to fill the remaining space. + * @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 the state us indeterminate + * @param pulsePeriod (theme flag: "progressbar.pulse") The time that it takes for one cycle of the + * pulse animation. 2 seconds by default. Set this to 0 or the theme flag to `false` to disable the pulse. + * @param fps The number of times per second to update the displayed bar. Uses the + * [animation fps][ProgressLayoutScope.animationFps] by default. + */ +fun ProgressLayoutScope<*>.progressBar( + width: Int? = 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, + pulsePeriod: Duration = 2.seconds, + verticalAlign: VerticalAlign = this.verticalAlign, + fps: Int = animationFps, +) = cell( + width?.let { ColumnWidth.Fixed(it) } ?: ColumnWidth.Expand(), + fps = fps, + verticalAlign = verticalAlign, +) { + val elapsedSeconds = animationTime.elapsedNow().toDouble(DurationUnit.SECONDS) + val period = pulsePeriod.toDouble(DurationUnit.SECONDS) + val showPulse = isRunning && period > 0 + val pulsePosition = if (showPulse) ((elapsedSeconds % period) / period) else 0 + + ProgressBar( + total = total ?: 0, + completed = completed, + indeterminate = isIndeterminate, + width = width, + pulsePosition = pulsePosition.toFloat(), + showPulse = if (showPulse) null else false, // null defaults to theme flag + pendingChar = pendingChar, + separatorChar = separatorChar, + completeChar = completeChar, + pendingStyle = pendingStyle, + separatorStyle = separatorStyle, + completeStyle = completeStyle, + finishedStyle = finishedStyle, + indeterminateStyle = indeterminateStyle + ) +} + +private fun renderDuration(duration: Duration?, compact: Boolean): String { + if (duration == null || duration < Duration.ZERO) { + return if (compact) "--:--" else "-:--:--" + } + return duration.toComponents { h, m, s, _ -> + val hrs = if (compact && h <= 0) "" else "$h:" + "$hrs${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}" + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressLayoutScope.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressLayoutScope.kt new file mode 100644 index 000000000..61cd8a95d --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressLayoutScope.kt @@ -0,0 +1,227 @@ +package com.github.ajalt.mordant.widgets.progress + +import com.github.ajalt.mordant.rendering.TextAlign +import com.github.ajalt.mordant.rendering.VerticalAlign +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.table.ColumnWidth +import kotlin.time.ComparableTimeMark + +internal const val TEXT_FPS = 5 +internal const val ANIMATION_FPS = 30 + +/** A unique identifier for a task in a progress bar. */ +class TaskId + +interface ProgressLayoutScope { + + /** The default framerate for text based cells */ + val textFps: Int + + /** The default framerate for animation cells */ + val animationFps: Int + + /** The default horizontal alignment for cells */ + val align: TextAlign + + /** The default vertical alignment for cells */ + val verticalAlign: VerticalAlign + + /** + * Add a cell to this layout. + * + * The [content] will be called every time the cell is rendered. In the case of + * animations, that will usually be at its [fps]. + * + * @param width The width of the cell. + * @param fps The number of times per second to refresh the cell when animated. If 0, the cell + * should not be refreshed. + * @param align The text alignment for the cell when multiple tasks are present and cells are + * aligned, or `null` to use [the default][ProgressLayoutScope.align]. + * @param verticalAlign The vertical alignment for the cell if there are other taller cells in + * the layout, or `null` to use [the default][ProgressLayoutScope.verticalAlign]. + * @param content A lambda returning the widget to display in this cell. + */ + fun cell( + width: ColumnWidth = ColumnWidth.Auto, + fps: Int = textFps, + align: TextAlign? = null, + verticalAlign: VerticalAlign? = null, + content: ProgressState.() -> Widget, + ) +} + +data class ProgressBarCell( + val columnWidth: ColumnWidth = ColumnWidth.Auto, + val fps: Int = TEXT_FPS, + val align: TextAlign = TextAlign.RIGHT, + val verticalAlign: VerticalAlign = VerticalAlign.BOTTOM, + val content: ProgressState.() -> Widget, +) { + init { + require(fps >= 0) { "fps cannot be negative" } + } +} + +/** + * The cells and configuration for a progress bar layout. + */ +interface ProgressBarDefinition { + /** + * The cells in this layout. + */ + val cells: List> + + /** + * The spacing between cells in this layout. + */ + val spacing: Int + + /** + * How to align the columns of the progress bar when multiple tasks are present. + */ + val alignColumns: Boolean +} + +private class ProgressBarDefinitionImpl( + override val cells: List>, + override val spacing: Int, + override val alignColumns: Boolean, +) : ProgressBarDefinition + +/** Create a new progress bar layout definition. */ +fun ProgressBarDefinition( + cells: List>, + spacing: Int, + alignColumns: Boolean, +): ProgressBarDefinition { + return ProgressBarDefinitionImpl(cells, spacing, alignColumns) +} + +/** Create a widget for this [ProgressBarDefinition] with the given [state]. */ +fun ProgressBarDefinition.build( + state: ProgressState, + maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker, +): Widget { + return maker.build(ProgressBarMakerRow(this, state)) +} + +/** Create a widget for this [ProgressBarDefinition] with the given state. */ +fun ProgressBarDefinition.build( + context: T, + total: Long?, + completed: Long, + displayedTime: ComparableTimeMark, + status: ProgressState.Status = ProgressState.Status.NotStarted, + speed: Double? = null, + maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker, +): Widget { + val state = ProgressState( + context, total, completed, displayedTime, status, speed + ) + return build(state, maker) +} + +/** Create a widget for this [ProgressBarDefinition] with the given state. */ +fun ProgressBarDefinition.build( + total: Long?, + completed: Long, + displayedTime: ComparableTimeMark, + status: ProgressState.Status = ProgressState.Status.NotStarted, + speed: Double? = null, + maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker, +): Widget { + return build(Unit, total, completed, displayedTime, status, speed, maker) +} + +/** + * Create a progress bar layout with that has a context of type [T]. + * + * If you don't need a context, you can use [progressBarLayout] instead. If you need a builder + * rather than a DSL, you can use [ProgressLayoutBuilder]. + * + * @param spacing The number of spaces between cells in this layout. + * @param alignColumns How to align the columns of the progress bar when multiple tasks are present. + * If `true`, the cells in each column will have the same width. Widths are only aligned for + * contiguous cells, so if you have a row with `alignColumns=false` between rows with + * `alignColumns=true`, none of the three will be aligned. + * @param textFps The default framerate for text based cells like `timeRemaining` + * @param animationFps The default framerate for animation cells like `progressBar` + * @param align The default horizontal alignment for cells + * @param verticalAlign The default vertical alignment for cells + * @param init A lambda that adds cells to the layout + */ +fun progressBarContextLayout( + spacing: Int = 2, + alignColumns: Boolean = true, + textFps: Int = TEXT_FPS, + animationFps: Int = ANIMATION_FPS, + align: TextAlign = TextAlign.RIGHT, + verticalAlign: VerticalAlign = VerticalAlign.BOTTOM, + init: ProgressLayoutScope.() -> Unit, +): ProgressBarDefinition { + return ProgressLayoutBuilder(textFps, animationFps, align, verticalAlign) + .apply(init) + .build(spacing, alignColumns) +} + +/** + * Create a progress bar layout that doesn't use a context. + * + * If you need a context, you can use [progressBarContextLayout] instead. + * + * @param spacing The number of spaces between cells in this layout. + * @param alignColumns How to align the columns of the progress bar when multiple tasks are present. + * If `true`, the cells in each column will have the same width. Width are only aligned for + * contiguous cells, so if you have a row with `alignColumns=false` between rows with + * `alignColumns=true`, none of the three will be aligned. + * @param textFps The default framerate for text based cells like `timeRemaining` + * @param animationFps The default framerate for animation cells like `progressBar` + * @param align The default horizontal alignment for cells + * @param verticalAlign The default vertical alignment for cells + * @param init A lambda that adds cells to the layout + */ +fun progressBarLayout( + spacing: Int = 2, + alignColumns: Boolean = true, + textFps: Int = TEXT_FPS, + animationFps: Int = ANIMATION_FPS, + align: TextAlign = TextAlign.RIGHT, + verticalAlign: VerticalAlign = VerticalAlign.BOTTOM, + init: ProgressLayoutScope.() -> Unit, +): ProgressBarDefinition { + return progressBarContextLayout( + spacing, alignColumns, textFps, animationFps, align, verticalAlign, init + ) +} + +/** + * A builder for creating a progress bar layout. + * + * If you don't want to use the [progressBarLayout] DSL, you can use this builder instead, and call + * [build] when you're done adding cells. + */ +class ProgressLayoutBuilder( + override val textFps: Int = TEXT_FPS, + override val animationFps: Int = ANIMATION_FPS, + override val align: TextAlign = TextAlign.RIGHT, + override val verticalAlign: VerticalAlign = VerticalAlign.BOTTOM, +) : ProgressLayoutScope { + private val cells: MutableList> = mutableListOf() + + override fun cell( + width: ColumnWidth, + fps: Int, + align: TextAlign?, + verticalAlign: VerticalAlign?, + content: ProgressState.() -> Widget, + ) { + cells += ProgressBarCell( + width, fps, align ?: this.align, verticalAlign ?: this.verticalAlign, content + ) + } + + /** Build the progress bar layout. */ + fun build(spacing: Int = 2, alignColumns: Boolean = true): ProgressBarDefinition { + return ProgressBarDefinition(cells, spacing, alignColumns) + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressState.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressState.kt new file mode 100644 index 000000000..8fc2b854b --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/ProgressState.kt @@ -0,0 +1,154 @@ +package com.github.ajalt.mordant.widgets.progress + +import com.github.ajalt.mordant.widgets.progress.ProgressState.Status +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +data class ProgressState( + /** The context object passed to the progress task. */ + val context: T, + /** The total number of steps needed to complete the progress task, or `null` if it is indeterminate. */ + val total: Long?, + /** The number of steps currently completed in the progress task. */ + val completed: Long, + /** + * The time that the progress layout was first constructed. + * + * Use this for continuous animations, since it's the same for all tasks. + */ + val animationTime: ComparableTimeMark, + + /** The running status of the task. */ + val status: Status, + + /** + * The estimated speed of the progress task, in steps per second, or `null` if it hasn't started. + * + * If the task is finished or paused, this is the speed at the time it finished. + */ + val speed: Double? = null, + + /** + * The unique id of this state's task. + */ + val taskId: TaskId = TaskId(), +) { + + sealed class Status { + data object NotStarted : Status() + data class Running( + val startTime: ComparableTimeMark, + ) : Status() + + data class Paused( + val startTime: ComparableTimeMark, + val pauseTime: ComparableTimeMark, + ) : Status() + + data class Finished( + val startTime: ComparableTimeMark, + val finishTime: ComparableTimeMark, + ) : Status() + } +} + +/** `true` if the task does not have a [total][ProgressState.total] specified. */ +val ProgressState.isIndeterminate: Boolean get() = total == null + +/** `true if the task's status is [Paused][Status.Paused]. */ +val ProgressState.isPaused: Boolean get() = status is Status.Paused + +/** `true` if the task's status is [Running][Status.Running]. */ +val ProgressState.isRunning: Boolean get() = status is Status.Running + +/** `true` if the task's status is [NotStarted][Status.NotStarted]. */ +val ProgressState.isFinished: Boolean get() = status is Status.Finished + +/** + * The time that this task started, or `null` if it hasn't started. + */ +val Status.startTime: ComparableTimeMark? + get() = when (this) { + is Status.NotStarted -> null + is Status.Running -> startTime + is Status.Paused -> startTime + is Status.Finished -> startTime + } + +/** + * The time that this task was paused, or `null` if it isn't paused. + */ +val Status.pauseTime: ComparableTimeMark? + get() = when (this) { + is Status.Paused -> pauseTime + else -> null + } + +/** + * The time that this task finished, or `null` if it isn't finished. + */ +val Status.finishTime: ComparableTimeMark? + get() = when (this) { + is Status.Finished -> finishTime + else -> null + } + +/** + * Calculate the estimated time remaining for this task, or `null` if the time cannot be estimated. + * + * @param elapsedWhenFinished If `true`, return the total elapsed time when the task is finished. + */ +fun ProgressState<*>.calculateTimeRemaining(elapsedWhenFinished: Boolean = true): Duration? { + return when { + status is Status.Finished && elapsedWhenFinished -> { + status.finishTime - status.startTime + } + + status is Status.Running && speed != null && speed > 0 && total != null -> { + ((total - completed) / speed).seconds + } + + else -> null + } +} + +/** + * Calculate the time elapsed for this task, or `null` if the task hasn't started. + * + * If the task is finished or paused, the elapsed time is the time between the start and + * finish/pause times. If the task is running, the elapsed time is the time between the start and + * now. + */ +fun ProgressState<*>.calculateTimeElapsed(): Duration? { + return when (status) { + Status.NotStarted -> null + is Status.Finished -> status.finishTime - status.startTime + is Status.Paused -> status.pauseTime - status.startTime + is Status.Running -> status.startTime.elapsedNow() + } +} + +/** + * Return the number of frames that have elapsed at the given [fps] since the start of the + * [animationTime][ProgressState.animationTime]. + */ +fun ProgressState<*>.frameCount(fps: Int): Int { + return (animationTime.elapsedNow().toDouble(DurationUnit.SECONDS) * fps).toInt() +} + +/** + * Create a [ProgressState] with no context. + */ +fun ProgressState( + total: Long?, + completed: Long, + animationTime: ComparableTimeMark, + status: Status, + speed: Double? = null, +): ProgressState { + return ProgressState( + Unit, total, completed, animationTime, status, speed + ) +} 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 index fd4a165b8..836280697 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt @@ -2,6 +2,7 @@ package com.github.ajalt.mordant.animation import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.terminal.TerminalRecorder +import com.github.ajalt.mordant.test.replaceCrLf import io.kotest.matchers.shouldBe import kotlin.js.JsName import kotlin.test.Test @@ -43,13 +44,13 @@ class AnimationTest { fun `animation size change`() { val a = t.textAnimation { it } a.update("1") - rec.output() shouldBe "1\n" + rec.output() shouldBe "1" // update rec.clearOutput() a.update("2\n3") - val moves = t.cursor.getMoves { startOfLine(); up(1) } - rec.output() shouldBe "${moves}2\n3\n" + val moves = t.cursor.getMoves { startOfLine() } + rec.output() shouldBe "${moves}2\n3" } @Test @@ -62,9 +63,7 @@ class AnimationTest { a.update(2) rec.output() shouldBe "1\r2" a.stop() - rec.output() shouldBe "1\r2" - a.clear() - rec.output() shouldBe "1\r2\r" + rec.output() shouldBe "1\r2\n" } @Test @@ -72,24 +71,24 @@ class AnimationTest { fun `print during animation`() { val a = t.textAnimation { "<$it>\n===" } a.update(1) - rec.output() shouldBe "<1>\n===\n" + rec.output() shouldBe "<1>\n===" // update rec.clearOutput() a.update(2) - var moves = t.cursor.getMoves { startOfLine(); up(2) } - rec.output() shouldBe "${moves}<2>\n===\n" + var moves = t.cursor.getMoves { startOfLine(); up(1) } + rec.output() shouldBe "${moves}<2>\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" + moves = t.cursor.getMoves { startOfLine(); up(1); clearScreenAfterCursor() } + rec.output() shouldBe "${moves}X\n<2>\n===" // clear rec.clearOutput() a.clear() - moves = t.cursor.getMoves { startOfLine(); up(2); clearScreenAfterCursor() } + moves = t.cursor.getMoves { startOfLine(); up(1); clearScreenAfterCursor() } rec.output() shouldBe moves // repeat clear @@ -100,12 +99,12 @@ class AnimationTest { // update after clear rec.clearOutput() a.update(3) - rec.output() shouldBe "<3>\n===\n" + rec.output() shouldBe "<3>\n===" // stop rec.clearOutput() a.stop() - rec.output() shouldBe "" + rec.output() shouldBe "\n" // print after stop rec.clearOutput() @@ -115,6 +114,37 @@ class AnimationTest { // update after stop rec.clearOutput() a.update(4) - rec.output() shouldBe "<4>\n===\n" + rec.output() shouldBe "<4>\n===" } + + @Test + @JsName("two_animations") + fun `two animations`() { + val a = t.textAnimation { "" } + val b = t.textAnimation { "" } + + a.update(1) + rec.output().replaceCrLf() shouldBe "" + rec.clearOutput() + + b.update(2) + var moves = t.cursor.getMoves { startOfLine() } + rec.output().replaceCrLf() shouldBe "${moves}\n".replaceCrLf() + rec.clearOutput() + + b.update(3) + moves = t.cursor.getMoves { + startOfLine(); up(1); clearScreenAfterCursor(); startOfLine() + } + rec.output().replaceCrLf() shouldBe "${moves}\n".replaceCrLf() + + rec.clearOutput() + a.stop() + rec.output().replaceCrLf() shouldBe "\r".replaceCrLf() + + rec.clearOutput() + b.stop() + rec.output().replaceCrLf() shouldBe "\n".replaceCrLf() + } + } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/progress/BaseProgressAnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/progress/BaseProgressAnimationTest.kt new file mode 100644 index 000000000..1fa6d02ae --- /dev/null +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/progress/BaseProgressAnimationTest.kt @@ -0,0 +1,176 @@ +package com.github.ajalt.mordant.animation.progress + +import com.github.ajalt.mordant.rendering.TextAlign +import com.github.ajalt.mordant.rendering.Theme +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.terminal.TerminalRecorder +import com.github.ajalt.mordant.test.RenderingTest +import com.github.ajalt.mordant.test.normalizedOutput +import com.github.ajalt.mordant.test.replaceCrLf +import com.github.ajalt.mordant.widgets.progress.* +import io.kotest.matchers.shouldBe +import kotlin.js.JsName +import kotlin.test.Test +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class BaseProgressAnimationTest : RenderingTest() { + private val now = TestTimeSource() + private val vt = TerminalRecorder(width = 54) + private val t = Terminal( + theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." }, + terminalInterface = vt + ) + + @Test + fun throttling() { + val l = progressBarLayout(spacing = 0, textFps = 1) { + speed() + text("|") + timeRemaining(fps = 1) + } + val a = MultiProgressBarAnimation(t, timeSource = now) + val pt = a.addTask(l, total = 1000) + + a.refresh() + vt.normalizedOutput() shouldBe " ---.-/s|eta -:--:--" + + now += 0.5.seconds + vt.clearOutput() + pt.update(40) + a.refresh() + vt.normalizedOutput() shouldBe " ---.-/s|eta -:--:--" + + now += 0.1.seconds + vt.clearOutput() + a.refresh() + vt.normalizedOutput() shouldBe " ---.-/s|eta -:--:--" + + now += 0.4.seconds + vt.clearOutput() + a.refresh() + vt.normalizedOutput() shouldBe " 40.0/s|eta 0:00:24" + + now += 0.9.seconds + vt.clearOutput() + a.refresh() + vt.normalizedOutput() shouldBe " 40.0/s|eta 0:00:24" + } + + @Test + fun animation() { + val l = progressBarLayout(spacing = 0, textFps = 1, animationFps = 1) { + text("text.txt") + text("|") + percentage() + text("|") + progressBar() + text("|") + completed() + text("|") + speed() + text("|") + timeRemaining() + } + val a = MultiProgressBarAnimation(t, false, 1.minutes, timeSource = now) + val pt = a.addTask(l, total = 100) + + a.refresh() + vt.normalizedOutput() shouldBe "text.txt| 0%|......| 0/100| ---.-/s|eta -:--:--" + + now += 10.0.seconds + vt.clearOutput() + pt.update(40) + a.refresh() + vt.normalizedOutput() shouldBe "text.txt| 40%|##>...| 40/100| 4.0/s|eta 0:00:15" + + now += 10.0.seconds + vt.clearOutput() + a.refresh() + vt.normalizedOutput() shouldBe "text.txt| 40%|##>...| 40/100| 2.0/s|eta 0:00:30" + + now += 10.0.seconds + vt.clearOutput() + pt.update { total = 200 } + a.refresh() + vt.normalizedOutput() shouldBe "text.txt| 20%|#>....| 40/200| 1.3/s|eta 0:02:00" + + vt.clearOutput() + pt.reset() + a.refresh() + vt.normalizedOutput() shouldBe "text.txt| 0%|......| 0/200| ---.-/s|eta -:--:--" + } + + @Test + @JsName("task_visibility") + fun `task visibility`() { + val l = progressBarContextLayout(textFps = 1, animationFps = 1) { + text { "Task $context" } + } + val a = MultiProgressBarAnimation(t, timeSource = now) + val t1 = a.addTask(l, 1) + val t2 = a.addTask(l, 2) + + a.refresh() + vt.normalizedOutput() shouldBe "Task 1\nTask 2" + + vt.clearOutput() + t1.update { visible = false } + a.refresh() + vt.normalizedOutput() shouldBe "Task 2" + + vt.clearOutput() + t2.update { visible = false } + a.refresh() + vt.normalizedOutput() shouldBe "" + + vt.clearOutput() + t1.update { visible = true } + a.refresh() + vt.normalizedOutput() shouldBe "Task 1" + + vt.clearOutput() + a.removeTask(t1) + a.refresh() + vt.normalizedOutput() shouldBe "" + } + + @Test + @JsName("changing_text_cell") + fun `changing text cell`() { + val l = progressBarContextLayout( + textFps = 1, animationFps = 1, alignColumns = false + ) { + text(align = TextAlign.LEFT) { context } + } + val a = MultiProgressBarAnimation(t, timeSource = now) + a.addTask(l, "====") + val t1 = a.addTask(l, "1111") + + a.refresh() + t1.update { context = "22" } + now += 1.seconds + a.refresh() + val moves = t.cursor.getMoves { startOfLine(); up(1) } + vt.output().replaceCrLf() shouldBe "====\n1111${moves}====\n22 ".replaceCrLf() + } + + @Test + @JsName("different_context_types") + fun `different context types`() { + val l1 = progressBarContextLayout { text { "int: $context" } } + val l2 = progressBarContextLayout { text { "string: $context" } } + val l3 = progressBarLayout { text { "no context" } } + val a = MultiProgressBarAnimation(t, timeSource = now) + a.addTask(l1, 11) + a.addTask(l2, "ss") + a.addTask(l3) + a.refresh() + vt.output() shouldBe """ + int: 11 + string: ss + no context + """.trimIndent() + } +} diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/LinearLayoutTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/LinearLayoutTest.kt index 73b368ba1..3bc74873a 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/LinearLayoutTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/LinearLayoutTest.kt @@ -1,8 +1,7 @@ package com.github.ajalt.mordant.table -import com.github.ajalt.mordant.rendering.TextAlign -import com.github.ajalt.mordant.rendering.TextStyle -import com.github.ajalt.mordant.rendering.Whitespace +import com.github.ajalt.mordant.rendering.* +import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.test.RenderingTest import com.github.ajalt.mordant.widgets.ProgressBar import com.github.ajalt.mordant.widgets.Text @@ -116,22 +115,48 @@ class LinearLayoutTest : RenderingTest() { ) @Test - fun verticalLayoutLeftAlign() = checkRender( + @JsName("verticalLayout_justify") + fun `verticalLayout justify`() = checkRender( verticalLayout { - align = TextAlign.LEFT + align = TextAlign.JUSTIFY spacing = 1 cell("1") - cell("2") - cell("33") + cell("2 2") + cell("3333") }, """ - ░1 ░ - ░ ░ - ░2 ░ - ░ ░ - ░33░ + ░ 1 ░ + ░ ░ + ░2 2░ + ░ ░ + ░3333░ """ ) + @Test + @JsName("verticalLayout_left_align") + fun `verticalLayout left align`() { + class W(val w: Int) : Widget { + override fun measure(t: Terminal, width: Int): WidthRange { + return WidthRange(w, w) + } + + override fun render(t: Terminal, width: Int): Lines { + return Lines(listOf(Line(listOf(Span.word("=".repeat(w)))))) + } + } + + checkRender( + verticalLayout { + align = TextAlign.LEFT + cell(W(2)) + cell(W(4)) + }, """ + ░== ░ + ░====░ + """ + ) + } + @Test fun verticalLayoutFixedTruncation() = checkRender( verticalLayout { @@ -156,4 +181,26 @@ class LinearLayoutTest : RenderingTest() { ░222 2░ """ ) + + @Test + @JsName("nesting_horizontalLayouts_in_verticalLayouts_with_fixed_column_width") + fun `nesting horizontalLayouts in verticalLayouts with fixed column width`() = checkRender( + verticalLayout { + cell(horizontalLayout { + spacing = 2 + align = TextAlign.RIGHT + column(0) { width = ColumnWidth.Fixed(4) } + cells("1", "1") + }) + cell(horizontalLayout { + spacing = 2 + align = TextAlign.RIGHT + column(0) { width = ColumnWidth.Fixed(4) } + cells("222", "2") + }) + }, """ + ░ 1 1░ + ░ 222 2░ + """ + ) } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableTest.kt index 58464fc06..7f3d194f0 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableTest.kt @@ -530,7 +530,8 @@ class TableTest : RenderingTest() { ) { val r = object : Widget { override fun measure(t: Terminal, width: Int) = WidthRange(1, 1) - override fun render(t: Terminal, width: Int) = Lines(listOf(Line(listOf(Span.word("!"))))) + override fun render(t: Terminal, width: Int) = + Lines(listOf(Line(listOf(Span.word("!"))))) } captionTop(r) captionBottom(r) @@ -549,6 +550,22 @@ class TableTest : RenderingTest() { """ ) + @Test + fun fixedWidthIncludesPadding() = doTest( + """ + ░┌──────┬────┬───┐ + ░│ 111 │ 222│ 33│ + ░└──────┴────┴───┘ + """ + ) { + addPaddingWidthToFixedWidth = true + padding = Padding { horizontal = 1 } + column(0) { width = ColumnWidth.Fixed(4) } + column(1) { width = ColumnWidth.Fixed(2) } + column(2) { width = ColumnWidth.Fixed(1) } + body { row(111, 222, 333) } + } + private fun doTest(expected: String, builder: TableBuilder.() -> Unit) { checkRender(table(builder), expected) } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt index ceea3a0b9..37dc6ed02 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt @@ -33,7 +33,7 @@ class HtmlRendererTest { @JsName("frame_no_body_tag") fun `frame no body tag`() { vt.outputAsHtml(includeCodeTag = false, includeBodyTag = false) shouldBe """ - |
\n
⏺ ⏺ ⏺ 
+ |
⏺ ⏺ ⏺ 
|
         |red redplain
         |blue blue
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
index 1dab4aff7..867c5397b 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
@@ -95,8 +95,6 @@ class TerminalColorsTest {
         color.color!!.toAnsi16().code shouldBe code
     }
 
-    // Disabled due to codegen bug on JS/IR
-    @Ignore
     @Test
     @JsName("all_24bit_colors")
     fun `all 24bit colors`() = forAll(
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt
index d36b3f026..3175ef39c 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt
@@ -69,6 +69,10 @@ class TerminalTest {
         vt.stdout() shouldBe t.cursor.getMoves { left(1); right(1) }
         vt.stderr() shouldBe t.cursor.getMoves { up(1) }
         vt.output() shouldBe t.cursor.getMoves { left(1); up(1); right(1) }
+
+        vt.clearOutput()
+        t.rawPrint("\t")
+        vt.output() shouldBe "\t"
     }
 
     @Test
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt
index 7c27a4fb1..aa28a3213 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt
@@ -26,6 +26,7 @@ abstract class RenderingTest(
             val trimmed = if (trimMargin) expected.trimMargin("░") else expected
             actual shouldBe trimmed.replace("░", "")
         } catch (e: Throwable) {
+            println()
             println(actual)
             throw e
         }
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt
index 03e60f8b1..1da61f7cc 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt
@@ -1,5 +1,8 @@
 package com.github.ajalt.mordant.test
 
+import com.github.ajalt.mordant.internal.CSI
+import com.github.ajalt.mordant.terminal.TerminalRecorder
+
 fun String.normalizeHyperlinks(): String {
     var i = 1
     val regex = Regex(";id=([^;]+);")
@@ -7,3 +10,11 @@ fun String.normalizeHyperlinks(): String {
     regex.findAll(this).forEach { map.getOrPut(it.value) { i++ } }
     return regex.replace(this) { ";id=${map[it.value]};" }
 }
+
+fun String.replaceCrLf(): String {
+    return replace("\r", "␍").replace("\n", "␊").replace(CSI, "␛")
+}
+
+fun TerminalRecorder.normalizedOutput(): String {
+    return output().substringAfter("${CSI}0J").substringAfter('\r')
+}
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/DeprecatedProgressLayoutTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/DeprecatedProgressLayoutTest.kt
new file mode 100644
index 000000000..47ef93063
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/DeprecatedProgressLayoutTest.kt
@@ -0,0 +1,108 @@
+package com.github.ajalt.mordant.widgets
+
+import com.github.ajalt.mordant.rendering.TextColors
+import com.github.ajalt.mordant.rendering.Theme
+import com.github.ajalt.mordant.test.RenderingTest
+import kotlin.js.JsName
+import kotlin.test.Test
+
+@Suppress("DEPRECATION")
+class DeprecatedProgressLayoutTest : RenderingTest() {
+    private val indetermStyle = Theme.Default.style("progressbar.indeterminate")
+
+    @Test
+    fun indeterminate() = doTest(
+        "text.txt|  0%|#########################|     0/---.-B| ---.-it/s",
+        0
+    )
+
+    @Test
+    @JsName("no_progress")
+    fun `no progress`() = doTest(
+        "text.txt|  0%|.........................|         0/0B| ---.-it/s",
+        0, 0
+    )
+
+    @Test
+    @JsName("large_values")
+    fun `large values`() = doTest(
+        "text.txt| 50%|############>............|150.0/300.0MB|100.0Mit/s",
+        150_000_000, 300_000_000, 1.5, 100_000_000.0
+    )
+
+    @Test
+    @JsName("short_eta")
+    fun `short eta`() = doTest(
+        "text.txt| 50%|############>............|         1/2B|   4.0it/s",
+        1, 2, 3.0, 4.0
+    )
+
+    @Test
+    @JsName("automatic_eta")
+    fun `automatic eta`() = doTest(
+        "text.txt| 50%|############>............|         1/2B|   0.3it/s",
+        1, 2, 3.0
+    )
+
+    @Test
+    @JsName("long_eta")
+    fun `long eta`() = doTest(
+        "text.txt| 50%|############>............|150.0/300.0MB|   2.0it/s",
+        150_000_000, 300_000_000, 1.5, 2.0
+    )
+
+    @Test
+    fun defaultPadding() = checkRender(
+        progressLayout {
+            text("1")
+            percentage()
+            text("2")
+            speed()
+            text("3")
+        }.build(0, 0, 0.0, 0.0),
+        "1    0%  2   ---.-it/s  3",
+    )
+
+    @Test
+    fun pulse() = checkRender(
+        progressLayout {
+            progressBar()
+        }.build(0, null, 1.0, 0.0),
+        indetermStyle("━${TextColors.rgb(1, 1, 1)("━")}━"),
+        width = 3,
+    )
+
+    @Test
+    @JsName("no_pulse")
+    fun `no pulse`() = checkRender(
+        progressLayout {
+            progressBar(showPulse = false)
+        }.build(0, null, 1.0, 0.0),
+        indetermStyle("━━━"),
+        width = 3,
+    )
+
+    private fun doTest(
+        expected: String,
+        completed: Long,
+        total: Long? = null,
+        elapsedSeconds: Double = 0.0,
+        completedPerSecond: Double? = null,
+    ) = checkRender(
+        progressLayout {
+            padding = 0
+            text("text.txt")
+            text("|")
+            percentage()
+            text("|")
+            progressBar()
+            text("|")
+            completed(suffix = "B")
+            text("|")
+            speed()
+        }.build(completed, total, elapsedSeconds, completedPerSecond),
+        expected,
+        width = 64,
+        theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
+    )
+}
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/MultiProgressLayoutTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/MultiProgressLayoutTest.kt
new file mode 100644
index 000000000..a58f41826
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/MultiProgressLayoutTest.kt
@@ -0,0 +1,192 @@
+package com.github.ajalt.mordant.widgets
+
+import com.github.ajalt.mordant.rendering.TextAlign
+import com.github.ajalt.mordant.rendering.Theme
+import com.github.ajalt.mordant.test.RenderingTest
+import com.github.ajalt.mordant.widgets.progress.*
+import com.github.ajalt.mordant.widgets.progress.ProgressState.Status.*
+import kotlin.js.JsName
+import kotlin.math.max
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TestTimeSource
+
+class MultiProgressLayoutTest : RenderingTest() {
+    @Test
+    fun indeterminate() = doTest(
+        completed1 = 0, total1 = null, elapsed1 = null, speed1 = null,
+        completed2 = 0, total2 = null, elapsed2 = null, speed2 = null,
+        expected = """
+        ░Task 1  |  0%|##############|     0/---.-| ---.-/s|eta -:--:--
+        ░Task Two|  0%|##############|     0/---.-| ---.-/s|eta -:--:--
+        """
+    )
+
+    @Test
+    @JsName("indeterminate_unaligned")
+    fun `indeterminate unaligned`() = doTest(
+        completed1 = 0, total1 = null, elapsed1 = null, speed1 = null,
+        completed2 = 0, total2 = null, elapsed2 = null, speed2 = null,
+        alignColumns = false,
+        expected = """
+        ░Task 1|  0%|################|     0/---.-| ---.-/s|eta -:--:--
+        ░Task Two|  0%|##############|     0/---.-| ---.-/s|eta -:--:--
+        """
+    )
+
+    @Test
+    @JsName("one_in_progress")
+    fun `one in progress`() = doTest(
+        completed1 = 5, total1 = 10, elapsed1 = 5.0, speed1 = 1.0,
+        completed2 = 0, total2 = null, elapsed2 = null, speed2 = null,
+        expected = """
+        ░Task 1  | 50%|#######>......|        5/10|   1.0/s|eta 0:00:05
+        ░Task Two|  0%|##############|     0/---.-| ---.-/s|eta -:--:--
+        """
+    )
+
+    @Test
+    @JsName("two_finished")
+    fun `two finished`() = doTest(
+        completed1 = 5, total1 = 10, elapsed1 = 5.0, speed1 = 1.0,
+        completed2 = 20, total2 = 20, elapsed2 = 10.0, speed2 = 2.0,
+        expected = """
+        ░Task 1  | 50%|#######>......|        5/10|   1.0/s|eta 0:00:05
+        ░Task Two|100%|##############|       20/20|   2.0/s|eta 0:00:00
+        """
+    )
+
+    @Test
+    @JsName("different_layouts")
+    fun `different layouts`() {
+        val t = TestTimeSource()
+        val animTime = t.markNow()
+        t += 5.seconds
+        val now = t.markNow()
+        val definition1 = progressBarContextLayout(spacing = 0) {
+            text(TextAlign.LEFT) { context }
+            text("|")
+            percentage()
+            text("|")
+            progressBar()
+            text("|")
+            completed()
+            text("|")
+            speed()
+            text("|")
+            timeRemaining()
+        }
+        val definition2 = progressBarContextLayout(spacing = 0) {
+            text("b")
+            text("|")
+            text { context }
+            text("|")
+            progressBar()
+        }
+        val definition3 = progressBarContextLayout(spacing = 0, alignColumns = false) {
+            text("cc")
+            text("|")
+            text { context }
+            text("|")
+            progressBar()
+        }
+        val widget = MultiProgressBarWidgetMaker.build(
+            definition1 to ProgressState(
+                context = "Task 1",
+                total = 10,
+                completed = 5,
+                animationTime = animTime,
+                status = Running(now - 5.seconds),
+                speed = 1.0,
+            ),
+            definition1 to ProgressState(
+                context = "Task 2",
+                total = 10,
+                completed = 5,
+                animationTime = animTime,
+                status = NotStarted,
+                speed = 1.0,
+            ),
+            definition2 to ProgressState(
+                context = "Task 3",
+                total = 2,
+                completed = 1,
+                animationTime = animTime,
+                status = Running(now - 10.seconds),
+            ),
+            definition3 to ProgressState(
+                context = "Task 4",
+                total = 2,
+                completed = 1,
+                animationTime = animTime,
+                status = Running(now - 10.seconds),
+            ),
+        )
+        checkRender(
+            widget,
+            """
+            ░Task 1|   50%|#######>......|        5/10|   1.0/s|eta 0:00:05░
+            ░Task 2|   50%|#######>......|        5/10|   1.0/s|eta -:--:--░
+            ░     b|Task 3|#######>......                                  ░
+            ░cc|Task 4|##########################>.........................░
+            """,
+            width = 62,
+            theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
+        )
+    }
+
+    private fun doTest(
+        completed1: Long,
+        total1: Long?,
+        elapsed1: Double?,
+        completed2: Long,
+        speed1: Double? = null,
+        total2: Long?,
+        elapsed2: Double?,
+        speed2: Double? = null,
+        expected: String,
+        alignColumns: Boolean = true,
+    ) {
+        val t = TestTimeSource()
+        val animTime = t.markNow()
+        t += max(elapsed1 ?: 0.0, elapsed2 ?: 0.0).seconds
+        val now = t.markNow()
+        val definition = progressBarContextLayout(spacing = 0, alignColumns = alignColumns) {
+            text(TextAlign.LEFT) { context }
+            text("|")
+            percentage()
+            text("|")
+            progressBar()
+            text("|")
+            completed()
+            text("|")
+            speed()
+            text("|")
+            timeRemaining()
+        }
+        val widget = MultiProgressBarWidgetMaker.build(
+            definition to ProgressState(
+                context = "Task 1",
+                total = total1,
+                completed = completed1,
+                animationTime = animTime,
+                status = if (elapsed1 == null) NotStarted else Running(now - elapsed1.seconds),
+                speed = speed1,
+            ),
+            definition to ProgressState(
+                context = "Task Two",
+                total = total2,
+                completed = completed2,
+                animationTime = animTime,
+                status = if (elapsed2 == null) NotStarted else Running(now - elapsed2.seconds),
+                speed = speed2,
+            ),
+        )
+        checkRender(
+            widget,
+            expected,
+            width = 62,
+            theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
+        )
+    }
+}
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressLayoutTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressLayoutTest.kt
index ef1337754..5d6ebf6e0 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressLayoutTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressLayoutTest.kt
@@ -1,102 +1,439 @@
 package com.github.ajalt.mordant.widgets
 
 import com.github.ajalt.mordant.rendering.TextColors
+import com.github.ajalt.mordant.rendering.TextColors.red
 import com.github.ajalt.mordant.rendering.Theme
+import com.github.ajalt.mordant.rendering.VerticalAlign
+import com.github.ajalt.mordant.table.table
 import com.github.ajalt.mordant.test.RenderingTest
+import com.github.ajalt.mordant.widgets.progress.*
+import com.github.ajalt.mordant.widgets.progress.ProgressState.Status.*
+import io.kotest.data.blocking.forAll
+import io.kotest.data.row
 import kotlin.js.JsName
 import kotlin.test.Test
+import kotlin.time.ComparableTimeMark
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.ZERO
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TestTimeSource
 
 class ProgressLayoutTest : RenderingTest() {
+    private val t = TestTimeSource()
+    private val start = t.markNow()
     private val indetermStyle = Theme.Default.style("progressbar.indeterminate")
 
+
+    @Test
+    @JsName("indeterminate_not_started")
+    fun `indeterminate not started`() = doTest(
+        "text.txt|  0%|#########|     0/---.-B| ---.-/s|eta -:--:--|-:--:--",
+        0, started = false
+    )
+
     @Test
-    fun indeterminate() = doTest(
-        0,
-        expected = "text.txt|  0%|#########################|   0.0/---.-B| ---.-it/s"
+    @JsName("indeterminate_started")
+    fun `indeterminate started`() = doTest(
+        "text.txt|  0%|#########|     0/---.-B| ---.-/s|eta -:--:--|0:00:00",
+        0
     )
 
     @Test
     @JsName("no_progress")
     fun `no progress`() = doTest(
-        0, 0,
-        expected = "text.txt|  0%|.........................|     0.0/0.0B| ---.-it/s"
+        "text.txt|  0%|.........|         0/0B| ---.-/s|eta -:--:--|0:00:00",
+        0, 0
     )
 
     @Test
     @JsName("large_values")
     fun `large values`() = doTest(
-        150_000_000, 300_000_000, 1.5, 100_000_000.0,
-        expected = "text.txt| 50%|############>............|150.0/300.0MB|100.0Mit/s"
+        "text.txt| 50%|####>....|150.0/300.0MB|100.0M/s|eta 0:00:01|4:10:33",
+        150_000_000, 300_000_000, 15033.0, 100_000_000.0
     )
 
     @Test
     @JsName("short_eta")
     fun `short eta`() = doTest(
-        1, 2, 3.0, 4.0,
-        expected = "text.txt| 50%|############>............|     1.0/2.0B|   4.0it/s"
+        "text.txt| 50%|####>....|         1/2B|   4.0/s|eta 0:00:00|0:00:03",
+        1, 2, 3.0, 4.0
     )
 
     @Test
     @JsName("long_eta")
     fun `long eta`() = doTest(
-        150_000_000, 300_000_000, 1.5, 2.0,
-        expected = "text.txt| 50%|############>............|150.0/300.0MB|   2.0it/s"
+        "text.txt| 50%|####>....|150.0/300.0MB|   2.0/s|eta -:--:--|0:00:01",
+        150_000_000, 300_000_000, 1.5, 2.0
     )
 
     @Test
-    fun defaultPadding() = checkRender(
-        progressLayout {
-            text("1")
+    @JsName("zero_total")
+    fun `zero total`() = doTest(
+        "text.txt|  0%|.........|         0/0B| ---.-/s|eta -:--:--|0:00:00",
+        0, 0
+    )
+
+    @Test
+    @JsName("negative_completed_value")
+    fun `negative completed value`() = doTest(
+        "text.txt|-50%|.........|        -1/2B| ---.-/s|eta -:--:--|0:00:00",
+        -1, 2
+    )
+
+    @Test
+    @JsName("completed_greater_than_total")
+    fun `completed value greater than total`() = doTest(
+        "text.txt|200%|#########|        10/5B| ---.-/s|eta -:--:--|0:00:00",
+        10, 5
+    )
+
+    @Test
+    @JsName("default_pacing")
+    fun `default spacing`() = checkRender(
+        progressBarLayout {
+            text("|")
             percentage()
-            text("2")
+            text("|")
             speed()
-            text("3")
-        }.build(0, 0, 0.0, 0.0),
-        "1    0%  2   ---.-it/s  3",
+            text("|")
+        }.build(null, 0, start),
+        "|    0%  |   ---.-/s  |",
     )
 
     @Test
-    fun pulse() = checkRender(
-        progressLayout {
-            progressBar()
-        }.build(0, null, 1.0, 0.0),
-        indetermStyle("━${TextColors.rgb(1, 1, 1)("━")}━"),
-        width = 3,
-    )
+    fun pulse() {
+        t += 1.seconds
+        checkRender(
+            progressBarLayout {
+                progressBar()
+            }.build(null, 0, start, Running(start)),
+            indetermStyle("━${TextColors.rgb(1, 1, 1)("━")}━"),
+            width = 3,
+        )
+    }
+
+    @Test
+    @JsName("custom_pulse_duration")
+    fun `custom pulse duration`() {
+        t += 0.5.seconds
+        checkRender(
+            progressBarLayout {
+                progressBar(pulsePeriod = 1.seconds)
+            }.build(null, 0, start, Running(start)),
+            indetermStyle("━${TextColors.rgb(1, 1, 1)("━")}━"),
+            width = 3,
+        )
+    }
 
     @Test
     @JsName("no_pulse")
     fun `no pulse`() {
+        t += 1.seconds
         checkRender(
-            progressLayout {
-                progressBar(showPulse = false)
-            }.build(0, null, 1.0, 0.0),
+            progressBarLayout {
+                progressBar(pulsePeriod = ZERO)
+            }.build(null, 0, start, Running(start)),
             indetermStyle("━━━"),
             width = 3,
         )
     }
 
+    @Test
+    @JsName("timeRemaining_compact")
+    fun `timeRemaining compact`() {
+        val l = progressBarLayout {
+            timeRemaining(compact = true)
+        }
+        t += 1.minutes
+        checkRender(
+            l.build(100, 90, start, Running(start), speed = .01),
+            "  eta 16:40", // 10remaining/.01hz == 1000s
+        )
+        checkRender(
+            l.build(100, 90, start, Running(start), speed = .001),
+            "eta 2:46:40", // 10remaining/.001hz == 10000s
+        )
+    }
+
+    @Test
+    @JsName("layout_no_cells")
+    fun `layout with no cells`() {
+        val layout = progressBarLayout { }.build(null, 0, start)
+        checkRender(layout, "")
+    }
+
+    @Test
+    @JsName("layout_no_states")
+    fun `layout with no states`() {
+        val layout = MultiProgressBarWidgetMaker.build(emptyList())
+        checkRender(layout, "")
+    }
+
+    @Test
+    @JsName("eta_and_remaining_compact")
+    fun `eta and remaining compact`() = forAll(
+        row(null, null, "--:--|  eta --:--"),
+        row(0.seconds, 1.seconds, "00:00|  eta 00:01"),
+        row(1.seconds, 0.seconds, "00:01|  eta --:--"),
+        row(1.hours - 1.seconds, 1.hours - 1.seconds, "59:59|  eta 59:59"),
+        row(1.hours, 1.hours, "1:00:00|eta 1:00:00"),
+        row(10.hours - 1.seconds, 10.hours - 1.seconds, "9:59:59|eta 9:59:59"),
+        row(10.hours, 10.hours, "10:00:00|  eta --:--"),
+        row(100.hours, 100.hours, "100:00:00|  eta --:--"),
+    ) { elapsed, remaining, expected ->
+        val speed = remaining?.let { if (it == ZERO) 0.0 else 100.0 / it.inWholeSeconds }
+        val start = setTime(elapsed ?: 0.seconds)
+        val status = if (elapsed == null) NotStarted else Running(start)
+        val layout = progressBarLayout(spacing = 0) {
+            timeElapsed(compact = true)
+            text("|")
+            timeRemaining(compact = true)
+        }.build(100, 0, start, status, speed = speed)
+        checkRender(layout, expected)
+    }
+
+    @Test
+    @JsName("eta_and_remaining_finished")
+    fun `eta and remaining finished`() {
+        t += 1.hours
+        val finishedTime = t.markNow()
+        t += 1.hours
+        checkRender(
+            etaLayout().build(100, 100, start, Finished(start, finishedTime), speed = 10.0),
+            "1:00:00|eta -:--:--"
+        )
+    }
+
+    @Test
+    @JsName("eta_and_remaining_paused")
+    fun `eta and remaining paused`() {
+        t += 1.hours
+        val pausedTime = t.markNow()
+        t += 1.hours
+        val layout = etaLayout().build(100, 50, start, Paused(start, pausedTime), speed = 10.0)
+        checkRender(layout, "1:00:00|eta -:--:--")
+    }
+
+    @Test
+    @JsName("eta_elapsedWhenFinished")
+    fun `eta elapsedWhenFinished`() {
+        val layout = etaLayout(elapsedWhenFinished = true)
+        t += 1.hours
+
+        checkRender(
+            layout.build(100, 25, start, Running(start), speed = 1.0),
+            "1:00:00|eta 0:01:15"
+        )
+
+        val finishedTime = t.markNow()
+        t += 1.hours
+        checkRender(
+            layout.build(100, 100, start, Finished(start, finishedTime), speed = 1.0),
+            "1:00:00| in 1:00:00"
+        )
+    }
+
+    @Test
+    fun spinner() = forAll(
+        row(0, "1"),
+        row(1, "2"),
+        row(2, "3"),
+        row(3, "1"),
+        row(4, "2"),
+        row(5, "3"),
+    ) { elapsed, expected ->
+        val layout = progressBarLayout { spinner(Spinner("123"), fps = 1) }
+        val start = setTime(elapsed.seconds)
+        checkRender(layout.build(null, 0, start, Running(start)), expected)
+    }
+
+    @Test
+    fun marquee() = forAll(
+        row(0, "   "),
+        row(1, "  1"),
+        row(2, " 12"),
+        row(3, "123"),
+        row(4, "234"),
+        row(5, "345"),
+        row(6, "45 "),
+        row(7, "5  "),
+        row(8, "   "),
+        row(9, "  1"),
+    ) { elapsed, expected ->
+        val layout = progressBarLayout { marquee("12345", width = 3, fps = 1) }
+        val start = setTime(elapsed.seconds)
+        checkRender(layout.build(null, 0, start), expected, trimMargin = false)
+    }
+
+    @Test
+    @JsName("styled_marquee")
+    fun `styled marquee`() = forAll(
+        row(0, red("   ")),
+        row(1, red("  1")),
+        row(2, red(" 12")),
+        row(3, red("123")),
+        row(4, red("234")),
+        row(5, red("345")),
+        row(6, red("45") + " "),
+        row(7, red("5") + "  "),
+        row(8, red("   ")),
+        row(9, red("  1")),
+    ) { elapsed, expected ->
+        val layout = progressBarLayout { marquee(red("12345"), width = 3, fps = 1) }
+        val start = setTime(elapsed.seconds)
+        checkRender(layout.build(null, 0, start), expected, trimMargin = false)
+    }
+
+    @Test
+    @JsName("completed_decimal_format")
+    fun `completed decimal format`() = forAll(
+        row(0, 0, null,/*      */" 0/---.-"),
+        row(0, 1e1, 1e2,/*     */"  10/100"),
+        row(0, 1e2, 1e3,/*     */"    0/1K"),
+        row(0, 1e9 - 1, 1e9 - 1, "999/999M"),
+        row(1, 0, null,/*      */"     0/---.-"),
+        row(1, 1e1, 1e2,/*     */"      10/100"),
+        row(1, 1e2, 1e3,/*     */"    0.1/1.0K"),
+        row(1, 1e2, 1e6,/*     */"    0.0/1.0M"),
+        row(1, 9e5, 1e6,/*     */"    0.9/1.0M"),
+        row(1, 9e6, 1e7,/*     */"   9.0/10.0M"),
+        row(1, 9e7, 1e8,/*     */" 90.0/100.0M"),
+        row(1, 9e8, 1e9,/*     */"    0.9/1.0G"),
+        row(1, 1e9 - 1, 1e9 - 1, "999.9/999.9M"),
+        row(2, 1e1, 1e2,/*     */"        10/100"),
+        row(2, 1e2, 1e3,/*     */"    0.10/1.00K"),
+        row(2, 1e2, 1e3,/*     */"    0.10/1.00K"),
+        row(2, 1e9 - 1, 1e9 - 1, "999.99/999.99M"),
+    ) { precision, completed, total, expected ->
+        val layout = progressBarLayout { completed(precision = precision) }
+        val widget = layout.build(total?.toLong(), completed.toLong(), start)
+        checkRender(widget, expected, trimMargin = false)
+    }
+
+    @Test
+    @JsName("marquee_scrollWhenContentFits_false")
+    fun `marquee scrollWhenContentFits=false`() {
+        val layout = progressBarLayout { marquee("123", width = 5) }
+        checkRender(layout.build(null, 0, start), "  123", trimMargin = false)
+    }
+
+    @Test
+    @JsName("marquee_scrollWhenContentFits_true")
+    fun `marquee scrollWhenContentFits=true`() {
+        val start = setTime(2.seconds)
+        val layout = progressBarLayout { marquee("123", width = 5, scrollWhenContentFits = true) }
+        checkRender(layout.build(null, 0, start), "23   ", trimMargin = false)
+    }
+
+
+    @Test
+    fun verticalAlign() {
+        val layout = progressBarLayout {
+            text("|\n|\n|")
+            text("1")
+            text("2", verticalAlign = VerticalAlign.TOP)
+            text("3", verticalAlign = VerticalAlign.MIDDLE)
+            text("4", verticalAlign = VerticalAlign.BOTTOM)
+            text("|\n|\n|")
+        }
+        checkRender(
+            layout.build(null, 0, start), """
+            ░|     2        |
+            ░|        3     |
+            ░|  1        4  |
+            """
+        )
+    }
+
+    @Test
+    fun buildCells() {
+        val layout = progressBarContextLayout {
+            text { "a$context" }
+            text { "b$context" }
+        }
+        val cells = MultiProgressBarWidgetMaker.buildCells(
+            listOf(
+                ProgressBarMakerRow(layout, ProgressState(1, 1, 1, start, Running(start))),
+                ProgressBarMakerRow(layout, ProgressState(2, 2, 2, start, Running(start))),
+            )
+        )
+        val widget = table {
+            body {
+                cells.forEach { rowFrom(it) }
+            }
+        }
+        checkRender(
+            widget, """
+            ░┌────┬────┐
+            ░│ a1 │ b1 │
+            ░├────┼────┤
+            ░│ a2 │ b2 │
+            ░└────┴────┘
+            """
+        )
+    }
+
+    @Test
+    @JsName("use_as_builder")
+    fun `use as builder`() {
+        val builder = ProgressLayoutBuilder()
+        builder.text { "a$context" }
+        builder.text { "b$context" }
+        val layout = builder.build()
+        checkRender(
+            layout.build(1, 2, 3, start, Running(start)), """
+            ░a1  b1
+            """
+        )
+    }
+
     private fun doTest(
+        expected: String,
         completed: Long,
         total: Long? = null,
         elapsedSeconds: Double = 0.0,
-        completedPerSecond: Double? = null,
-        expected: String,
-    ) = checkRender(
-        progressLayout {
-            padding = 0
-            text("text.txt")
-            text("|")
-            percentage()
-            text("|")
-            progressBar()
-            text("|")
-            completed(suffix = "B")
+        speed: Double? = null,
+        started: Boolean = true,
+    ) {
+        t += elapsedSeconds.seconds
+        val status = if (started) Running(start) else NotStarted
+        checkRender(
+            progressBarLayout(spacing = 0) {
+                text("text.txt")
+                text("|")
+                percentage()
+                text("|")
+                progressBar()
+                text("|")
+                completed(suffix = "B")
+                text("|")
+                speed()
+                text("|")
+                timeRemaining()
+                text("|")
+                timeElapsed()
+            }.build(total, completed, start, status, speed = speed),
+            expected,
+            width = 66,
+            theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
+        )
+    }
+
+    private fun etaLayout(elapsedWhenFinished: Boolean = false): ProgressBarDefinition {
+        return progressBarLayout(spacing = 0) {
+            timeElapsed()
             text("|")
-            speed()
-        }.build(completed, total, elapsedSeconds, completedPerSecond),
-        expected,
-        width = 64,
-        theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
-    )
+            timeRemaining(elapsedWhenFinished = elapsedWhenFinished)
+        }
+    }
+
+    // this is separate from [t] for use in forAll, which doesn't reset the state between rows
+    private fun setTime(elapsed: Duration = 0.seconds): ComparableTimeMark {
+        val t = TestTimeSource()
+        val start = t.markNow()
+        t += elapsed
+        return start
+    }
 }
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ViewportTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ViewportTest.kt
new file mode 100644
index 000000000..5d7153cac
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ViewportTest.kt
@@ -0,0 +1,61 @@
+package com.github.ajalt.mordant.widgets
+
+import com.github.ajalt.mordant.rendering.TextColors.red
+import com.github.ajalt.mordant.test.RenderingTest
+import io.kotest.data.blocking.forAll
+import io.kotest.data.row
+import kotlin.js.JsName
+import kotlin.test.Test
+
+class ViewportTest : RenderingTest(width = 20) {
+    @Test
+    fun crop() = forAll(
+        row(null, null, "a  ␊b c"),
+        row(null, 1, "a  "),
+        row(1, 1, "a"),
+        row(2, 1, "a "),
+        row(1, null, "a␊b"),
+        row(2, null, "a ␊b "),
+        row(3, null, "a  ␊b c"),
+        row(2, 2, "a ␊b "),
+        row(4, 3, "a   ␊b c ␊    "),
+        row(0, 0, ""),
+    ) { w, h, ex ->
+        doTest(w, h, 0, 0, ex, "a\nb c")
+    }
+
+    @Test
+    fun scroll() = forAll(
+        row(0, 0, "a  ␊b c"),
+        row(1, 0, "   ␊ c "),
+        row(2, 0, "   ␊c  "),
+        row(3, 0, "   ␊   "),
+        row(4, 0, "   ␊   "),
+        row(0, 1, "b c␊   "),
+        row(0, 2, "   ␊   "),
+        row(1, 1, " c ␊   "),
+        row(9, 9, "   ␊   "),
+        row(-1, 0, " a ␊ b "),
+        row(-2, 0, "  a␊  b"),
+        row(-3, 0, "   ␊   "),
+        row(0, -1, "   ␊a  "),
+        row(0, -2, "   ␊   "),
+        row(-9, -9, "   ␊   "),
+    ) { x, y, ex ->
+        doTest(null, null, x, y, ex, "a\nb c")
+    }
+
+    @Test
+    @JsName("scrolling_splits_span")
+    fun `scrolling splits span`() {
+        doTest(1, 1, 2, 0, red("Z"), "X${red("YZ")}")
+        doTest(2, 1, 1, 0, "56", "4567")
+    }
+
+    private fun doTest(w: Int?, h: Int?, x: Int, y: Int, ex: String, txt: String) {
+        checkRender(Viewport(Text(txt), w, h, x, y), ex, trimMargin = false) {
+            it.replace('\n', '␊')
+        }
+    }
+}
+
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 e9b091c28..a56d6018e 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
@@ -7,6 +7,20 @@ private external val console: dynamic
 private external val Symbol: dynamic
 private external val Buffer: dynamic
 
+private class JsAtomicRef(override var value: T) : MppAtomicRef {
+    override fun compareAndSet(expected: T, newValue: T): Boolean {
+        if (value != expected) return false
+        value = newValue
+        return true
+    }
+
+    override fun getAndSet(newValue: T): T {
+        val old = value
+        value = newValue
+        return old
+    }
+}
+
 private class JsAtomicInt(initial: Int) : MppAtomicInt{
     private var backing = initial
     override fun getAndIncrement(): Int {
@@ -23,6 +37,7 @@ private class JsAtomicInt(initial: Int) : MppAtomicInt{
 }
 
 internal actual fun MppAtomicInt(initial: Int): MppAtomicInt = JsAtomicInt(initial)
+internal actual fun  MppAtomicRef(value: T): MppAtomicRef = JsAtomicRef(value)
 
 
 private interface JsMppImpls {
@@ -30,7 +45,7 @@ private interface JsMppImpls {
     fun stdoutInteractive(): Boolean
     fun stdinInteractive(): Boolean
     fun stderrInteractive(): Boolean
-    fun getTerminalSize(): Pair?
+    fun getTerminalSize(): Size?
     fun printStderr(message: String, newline: Boolean)
     fun readLineOrNull(): String?
 }
@@ -40,7 +55,7 @@ private object BrowserMppImpls : JsMppImpls {
     override fun stdoutInteractive(): Boolean = false
     override fun stdinInteractive(): Boolean = false
     override fun stderrInteractive(): Boolean = false
-    override fun getTerminalSize(): Pair? = null
+    override fun getTerminalSize(): Size? = null
     override fun printStderr(message: String, newline: Boolean) {
         // No way to avoid the newline on browsers
         console.error(message)
@@ -55,12 +70,12 @@ private class NodeMppImpls(private val fs: dynamic) : JsMppImpls {
     override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean
     override fun stdinInteractive(): Boolean = js("Boolean(process.stdin.isTTY)") as Boolean
     override fun stderrInteractive(): Boolean = js("Boolean(process.stderr.isTTY)") as Boolean
-    override fun getTerminalSize(): Pair? {
+    override fun getTerminalSize(): Size? {
         // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY
         // is false
         if (process.stdout.getWindowSize == undefined) return null
         val s = process.stdout.getWindowSize()
-        return s[0] as Int to s[1] as Int
+        return Size(width = s[0] as Int, height =  s[1] as Int)
     }
 
     override fun printStderr(message: String, newline: Boolean) {
@@ -93,7 +108,7 @@ private val impls: JsMppImpls = try {
 
 internal actual fun runningInIdeaJavaAgent(): Boolean = false
 
-internal actual fun getTerminalSize(): Pair? = impls.getTerminalSize()
+internal actual fun getTerminalSize(): Size? = impls.getTerminalSize()
 internal actual fun getEnv(key: String): String? = impls.readEnvvar(key)
 internal actual fun stdoutInteractive(): Boolean = impls.stdoutInteractive()
 internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive()
@@ -140,4 +155,4 @@ internal actual fun sendInterceptedPrintRequest(
     )
 }
 
-internal actual inline fun synchronizeJvm(lock: Any, block: () -> Unit) = block()
+internal actual val FAST_ISATTY: Boolean = true
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/ProgressAnimation.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/ProgressAnimation.kt
index 1f40fe6fb..68fd16af9 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/ProgressAnimation.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/ProgressAnimation.kt
@@ -1,18 +1,24 @@
+@file:Suppress("DEPRECATION")
+
 package com.github.ajalt.mordant.animation
 
-import com.github.ajalt.mordant.internal.nanosToSeconds
-import com.github.ajalt.mordant.rendering.Widget
-import com.github.ajalt.mordant.table.ColumnWidth
+import com.github.ajalt.mordant.animation.progress.*
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.widgets.ProgressBuilder
-import com.github.ajalt.mordant.widgets.ProgressCell
-import com.github.ajalt.mordant.widgets.ProgressCell.AnimationRate
 import com.github.ajalt.mordant.widgets.ProgressLayout
-import com.github.ajalt.mordant.widgets.ProgressState
-import java.util.concurrent.TimeUnit
-
-
-class ProgressAnimationBuilder internal constructor() : ProgressBuilder() {
+import com.github.ajalt.mordant.widgets.progress.ANIMATION_FPS
+import com.github.ajalt.mordant.widgets.progress.ProgressBarDefinition
+import com.github.ajalt.mordant.widgets.progress.ProgressLayoutBuilder
+import com.github.ajalt.mordant.widgets.progress.TEXT_FPS
+import java.util.concurrent.Future
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeSource
+
+
+@Deprecated("Use progressBarLayout instead")
+class ProgressAnimationBuilder internal constructor() : ProgressBuilder(
+    ProgressLayoutBuilder()
+) {
     /**
      * The maximum number of times per second to update idle animations like the progress bar pulse
      * (default: 30fps)
@@ -30,96 +36,29 @@ class ProgressAnimationBuilder internal constructor() : ProgressBuilder() {
      * time remaining (default: 30s)
      */
     var historyLength: Float = 30f
-
-    // for testing
-    internal var timeSource: () -> Long = { System.nanoTime() }
-}
-
-
-private class ProgressHistoryEntry(val timeNs: Long, val completed: Long)
-private class ProgressHistory(windowLengthSeconds: Float, private val timeSource: () -> Long) {
-    private var startTime: Long = -1
-    private val samples = ArrayDeque()
-    private val windowLengthNs = (TimeUnit.SECONDS.toNanos(1) * windowLengthSeconds).toLong()
-
-    fun start() {
-        if (!started) {
-            startTime = timeSource()
-        }
-    }
-
-    fun clear() {
-        startTime = -1
-        samples.clear()
-    }
-
-    fun update(completed: Long) {
-        start()
-        val now = timeSource()
-        val keepTime = now - windowLengthNs
-        while (samples.firstOrNull().let { it != null && it.timeNs < keepTime }) {
-            samples.removeFirst()
-        }
-        samples.addLast(ProgressHistoryEntry(now, completed))
-    }
-
-    fun makeState(total: Long?) = ProgressState(
-        completed = completed,
-        total = total,
-        completedPerSecond = completedPerSecond,
-        elapsedSeconds = elapsedSeconds,
-    )
-
-    val started: Boolean get() = startTime >= 0
-    val completed: Long get() = samples.lastOrNull()?.completed ?: 0
-
-    private val elapsedSeconds: Double
-        get() = if (startTime >= 0) nanosToSeconds(timeSource() - startTime) else 0.0
-
-    private val completedPerSecond: Double
-        get() {
-            if (startTime < 0 || samples.size < 2) return 0.0
-            val sampleTimespan = nanosToSeconds(samples.last().timeNs - samples.first().timeNs)
-            val complete = samples.last().completed - samples.first().completed
-            return if (complete <= 0 || sampleTimespan <= 0) 0.0 else complete / sampleTimespan
-        }
 }
 
 /**
  * A pretty animated progress bar. Manages a timer thread to update the progress bar, so be sure to [stop] it when you're done.
  */
+@Deprecated("Use progressBarLayout instead")
 class ProgressAnimation internal constructor(
-    private val t: Terminal,
-    private val layout: ProgressLayout,
-    historyLength: Float,
-    private val ticker: Ticker,
-    timeSource: () -> Long,
+    private val inner: ThreadProgressTaskAnimator,
 ) {
-    private var total: Long? = null
-    private var tickerStarted: Boolean = false
-    private val history = ProgressHistory(historyLength, timeSource)
-    private val animation = t.animation {
-        val state = history.makeState(total)
-        layout.build(state.completed, state.total, state.elapsedSeconds, state.completedPerSecond)
-    }
-
-    // Locking: all state is protected by this object's monitor. Tick is run on the timer thread.
+    private val lock = Any()
+    private var future: Future<*>? = null
 
     /**
      * Set the current progress to the [completed] value.
      */
-    @Synchronized
     fun update(completed: Long) {
-        history.update(completed)
-        if (!tickerStarted) {
-            animation.update(Unit)
-        }
+        inner.update(completed)
+        update()
     }
 
     /**
      * Set the current progress to the [completed] value.
      */
-    @Synchronized
     fun update(completed: Int) {
         update(completed.toLong())
     }
@@ -129,63 +68,45 @@ class ProgressAnimation internal constructor(
      *
      * This will redraw the animation and update fields like the estimated time remaining.
      */
-    @Synchronized
     fun update() {
-        update(history.completed)
+        inner.refresh()
     }
 
     /**
      * Set the current progress to the [completed] value, and set the total to the [total] value.
      */
-    @Synchronized
     fun update(completed: Long, total: Long?) {
-        updateTotalWithoutAnimation(total)
-        update(completed)
+        inner.update {
+            this.completed = completed
+            this.total = total
+        }
+        update()
     }
 
     /**
      * Set the [total] amount of work to be done, or `null` to make the progress bar indeterminate.
      */
-    @Synchronized
     fun updateTotal(total: Long?) {
-        updateTotalWithoutAnimation(total)
+        inner.update { this.total = total }
         update()
     }
 
-    @Synchronized
-    private fun updateTotalWithoutAnimation(total: Long?) {
-        this.total = total?.takeIf { it > 0 }
-    }
-
     /**
      * Advance the current completed progress by [amount] without changing the total.
      */
-    @Synchronized
     fun advance(amount: Long = 1) {
-        update(history.completed + amount)
+        inner.advance(amount)
+        if(!inner.finished) {
+            update()
+        }
     }
 
     /**
      * Start the progress bar animation.
      */
-    @Synchronized
-    fun start() {
-        if (tickerStarted) return
-        t.cursor.hide(showOnExit = true)
-        tickerStarted = true
-        history.start()
-        ticker.start {
-            tick()
-        }
-    }
-
-    @Synchronized
-    private fun tick() {
-        // Running on the timer thread.
-        if (!tickerStarted)
-            return   // This can happen if we're racing with stop().
-        update()
-        animation.update(Unit)
+    fun start() = synchronized(lock) {
+        if (future != null) return
+        future = inner.execute()
     }
 
     /**
@@ -194,28 +115,17 @@ class ProgressAnimation internal constructor(
      * The progress bar will remain on the screen until you call [clear].
      * You can call [start] again to resume the animation.
      */
-    @Synchronized
-    fun stop() {
-        if (!tickerStarted) return
-        tickerStarted = false
-        try {
-            ticker.stop()
-            animation.stop()
-        } finally {
-            t.cursor.show()
-        }
+    fun stop() = synchronized(lock) {
+        future?.cancel(false)
+        future = null
     }
 
     /**
      * Set the progress to 0 and restart the animation.
      */
-    @Synchronized
-    fun restart() {
-        val tickerStarted = tickerStarted
-        stop()
-        layout.cells.forEach { (it as? CachedProgressCell)?.clear() }
-        update(0)
-        if (tickerStarted) start()
+    fun restart() = synchronized(lock) {
+        inner.reset()
+        if (future == null) update()
     }
 
     /**
@@ -223,41 +133,9 @@ class ProgressAnimation internal constructor(
      *
      * If you want to leave the animation on the screen, call [stop] instead.
      */
-    @Synchronized
-    fun clear() {
+    fun clear() = synchronized(lock) {
         stop()
-        history.clear()
-        animation.clear()
-    }
-}
-
-private class CachedProgressCell(private val cell: ProgressCell, frameRate: Int?) : ProgressCell {
-    override val columnWidth: ColumnWidth get() = cell.columnWidth
-    override val animationRate: AnimationRate get() = cell.animationRate
-    private val frameDuration = frameRate?.let { 1.0 / it }
-
-    private var widget: Widget? = null
-    private var lastFrameTime = 0.0
-
-    fun clear() {
-        widget = null
-        lastFrameTime = 0.0
-    }
-
-    private fun shouldSkipUpdate(elapsed: Double): Boolean {
-        if (frameDuration == null) return false
-        if ((elapsed - lastFrameTime) < frameDuration) return true
-        lastFrameTime = elapsed
-        return false
-    }
-
-    override fun ProgressState.makeWidget(): Widget {
-        var r = widget
-        val shouldSkipUpdate = shouldSkipUpdate(elapsedSeconds)
-        if (r != null && shouldSkipUpdate) return r
-        r = cell.run { makeWidget() }
-        widget = r
-        return r
+        inner.clear()
     }
 }
 
@@ -266,23 +144,31 @@ private class CachedProgressCell(private val cell: ProgressCell, frameRate: Int?
  *
  * See [ProgressLayout] for the types of cells that can be added.
  */
+@Deprecated("Use progressBarLayout instead")
 fun Terminal.progressAnimation(init: ProgressAnimationBuilder.() -> Unit): ProgressAnimation {
-    val builder = ProgressAnimationBuilder().apply(init)
-
-    val layout = ProgressLayout(builder.cells.map {
-        val fr = when (it.animationRate) {
-            AnimationRate.STATIC -> null
-            AnimationRate.ANIMATION -> builder.animationFrameRate
-            AnimationRate.TEXT -> builder.textFrameRate
-        }
-        CachedProgressCell(it, fr)
-    }, builder.padding)
+    return progressAnimation(TimeSource.Monotonic, init)
+}
 
-    return ProgressAnimation(
-        t = this,
-        layout = layout,
-        historyLength = builder.historyLength,
-        ticker = getTicker(builder.animationFrameRate),
-        timeSource = builder.timeSource
-    )
+// internal for testing
+internal fun Terminal.progressAnimation(
+    timeSource: TimeSource.WithComparableMarks,
+    init: ProgressAnimationBuilder.() -> Unit,
+): ProgressAnimation {
+    val builder = ProgressAnimationBuilder().apply(init)
+    val origDef = builder.builder.build(builder.padding, true)
+    // Since the new builder requires the fps upfront, we copy the cells and replace the fps
+    val cells = origDef.cells.mapTo(mutableListOf()) {
+        it.copy(
+            fps = when (it.fps) {
+                TEXT_FPS -> builder.textFrameRate
+                ANIMATION_FPS -> builder.animationFrameRate
+                else -> it.fps
+            }
+        )
+    }
+    val definition = ProgressBarDefinition(cells, origDef.spacing, origDef.alignColumns)
+    return ProgressAnimation(definition.animateOnThread(this,
+        timeSource = timeSource,
+        speedEstimateDuration = builder.historyLength.toDouble().seconds
+        ))
 }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Ticker.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Ticker.kt
deleted file mode 100644
index 9117aaf4c..000000000
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Ticker.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.github.ajalt.mordant.animation
-
-import java.util.*
-import kotlin.concurrent.timer
-
-internal interface Ticker {
-    fun start(onTick: () -> Unit)
-    fun stop()
-}
-
-internal fun getTicker(ticksPerSecond: Int?): Ticker {
-    return if (ticksPerSecond == null) DisabledTicker() else JvmTicker(ticksPerSecond)
-}
-
-private class JvmTicker(private val ticksPerSecond: Int) : Ticker {
-    private var timer: Timer? = null
-    override fun start(onTick: () -> Unit) {
-        if (timer != null) return
-        val period = 1000L / ticksPerSecond
-        timer = timer(startAt = Date(0), period = period) { onTick() }
-    }
-
-    override fun stop() {
-        timer?.cancel()
-        timer = null
-    }
-}
-
-private class DisabledTicker : Ticker {
-    override fun start(onTick: () -> Unit) {}
-    override fun stop() {}
-}
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimator.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimator.kt
new file mode 100644
index 000000000..b32c82bc5
--- /dev/null
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimator.kt
@@ -0,0 +1,287 @@
+package com.github.ajalt.mordant.animation.progress
+
+import com.github.ajalt.mordant.animation.Animation
+import com.github.ajalt.mordant.animation.RefreshableAnimation
+import com.github.ajalt.mordant.animation.asRefreshable
+import com.github.ajalt.mordant.animation.refreshPeriod
+import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.widgets.progress.MultiProgressBarWidgetMaker
+import com.github.ajalt.mordant.widgets.progress.ProgressBarDefinition
+import com.github.ajalt.mordant.widgets.progress.ProgressBarWidgetMaker
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.ThreadFactory
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeSource
+
+
+interface BlockingAnimator : RefreshableAnimation {
+    /**
+     * Start the animation and refresh it until all its tasks are finished.
+     *
+     * This calls [Thread.sleep] between each frame, so it should usually be run in a separate
+     * thread so that you can update the state concurrently.
+     *
+     * @see execute
+     */
+    fun runBlocking()
+}
+
+/**
+ * A [BlockingAnimator] for a single [task][ProgressTask].
+ */
+interface ThreadProgressTaskAnimator : BlockingAnimator, ProgressTask
+
+/**
+ * A [BlockingAnimator] for a [ProgressBarAnimation].
+ */
+interface ThreadProgressAnimator : BlockingAnimator, ProgressBarAnimation
+
+class BaseBlockingAnimator(
+    private val terminal: Terminal,
+    private val animation: RefreshableAnimation,
+) : BlockingAnimator {
+    private var stopped = false
+    private val lock = Any()
+
+    override fun runBlocking() {
+        synchronized(lock) {
+            stopped = false
+            terminal.cursor.hide(showOnExit = true)
+        }
+        while (synchronized(lock) { !stopped && !animation.finished }) {
+            synchronized(lock) { animation.refresh(refreshAll = false) }
+            Thread.sleep(animation.refreshPeriod.inWholeMilliseconds)
+        }
+        synchronized(lock) {
+            // final refresh to show finished state
+            if (!stopped) animation.refresh(refreshAll = true)
+        }
+    }
+
+    override fun stop(): Unit = synchronized(lock) {
+        if (stopped) return@synchronized
+        animation.stop()
+        terminal.cursor.show()
+        stopped = true
+    }
+
+    override fun clear(): Unit = synchronized(lock) {
+        if (stopped) return@synchronized
+        animation.clear()
+        terminal.cursor.show()
+        stopped = true
+    }
+
+    override val finished: Boolean get() = animation.finished
+
+    override fun refresh(refreshAll: Boolean) {
+        animation.refresh(refreshAll)
+    }
+}
+
+class BlockingProgressBarAnimation private constructor(
+    private val animation: ProgressBarAnimation,
+    private val animator: BlockingAnimator,
+) : ProgressBarAnimation by animation, BlockingAnimator by animator {
+    private constructor(
+        terminal: Terminal,
+        animation: MultiProgressBarAnimation,
+    ) : this(animation, BaseBlockingAnimator(terminal, animation))
+
+    constructor(
+        terminal: Terminal,
+        clearWhenFinished: Boolean = false,
+        speedEstimateDuration: Duration = 30.seconds,
+        maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker,
+        timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic,
+    ) : this(
+        terminal,
+        MultiProgressBarAnimation(
+            terminal, clearWhenFinished, speedEstimateDuration, maker, timeSource
+        ),
+    )
+
+    override fun refresh(refreshAll: Boolean) = animator.refresh(refreshAll)
+    override val finished: Boolean get() = animator.finished
+}
+
+/**
+ * Create a progress bar animation with a single task that runs synchronously.
+ *
+ * Use [execute] to run the animation on a background thread.
+ *
+ * ### Example
+ *
+ * ```
+ * val animation = progressBarContextLayout { ... }.animateOnThread(terminal, "context")
+ * animation.execute()
+ * animation.update { ... }
+ * ```
+ *
+ * @param terminal The terminal to draw the progress bar to
+ * @param context The context to pass to the task
+ * @param total The total number of steps needed to complete the progress task, or `null` if it is indeterminate.
+ * @param completed The number of steps currently completed in the progress task.
+ * @param start If `true`, start the task immediately.
+ * @param visible If `false`, the task will not be drawn to the screen.
+ * @param clearWhenFinished If `true`, the animation will be cleared when all tasks are finished. Otherwise, the animation will stop when all tasks are finished, but remain on the screen.
+ * @param speedEstimateDuration The duration over which to estimate the speed of the progress tasks. This estimate will be a rolling average over this duration.
+ * @param timeSource The time source to use for the animation.
+ * @param maker The widget maker to use to lay out the progress bars.
+ */
+fun  ProgressBarDefinition.animateOnThread(
+    terminal: Terminal,
+    context: T,
+    total: Long? = null,
+    completed: Long = 0,
+    start: Boolean = true,
+    visible: Boolean = true,
+    clearWhenFinished: Boolean = false,
+    speedEstimateDuration: Duration = 30.seconds,
+    timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic,
+    maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker,
+): ThreadProgressTaskAnimator {
+    val animation = BlockingProgressBarAnimation(
+        terminal,
+        clearWhenFinished,
+        speedEstimateDuration,
+        maker,
+        timeSource
+    )
+    val task = animation.addTask(this, context, total, completed, start, visible)
+    return ThreadProgressTaskAnimatorImpl(task, animation)
+}
+
+/**
+ * Create a progress bar animation for a single task that runs synchronously.
+ *
+ * Use [execute] to run the animation on a background thread.
+ *
+ * ### Example
+ *
+ * ```
+ * val animation = progressBarLayout { ... }.animateOnThread(terminal)
+ * animation.execute()
+ * animation.update { ... }
+ * ```
+ * @param terminal The terminal to draw the progress bar to
+ * @param total The total number of steps needed to complete the progress task, or `null` if it is indeterminate.
+ * @param completed The number of steps currently completed in the progress task.
+ * @param start If `true`, start the task immediately.
+ * @param visible If `false`, the task will not be drawn to the screen.
+ * @param clearWhenFinished If `true`, the animation will be cleared when all tasks are finished. Otherwise, the animation will stop when all tasks are finished, but remain on the screen.
+ * @param speedEstimateDuration The duration over which to estimate the speed of the progress tasks. This estimate will be a rolling average over this duration.
+ * @param timeSource The time source to use for the animation.
+ * @param maker The widget maker to use to lay out the progress bars.
+ */
+fun ProgressBarDefinition.animateOnThread(
+    terminal: Terminal,
+    total: Long? = null,
+    completed: Long = 0,
+    start: Boolean = true,
+    visible: Boolean = true,
+    clearWhenFinished: Boolean = false,
+    speedEstimateDuration: Duration = 30.seconds,
+    timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic,
+    maker: ProgressBarWidgetMaker = MultiProgressBarWidgetMaker,
+): ThreadProgressTaskAnimator {
+    return animateOnThread(
+        terminal = terminal,
+        context = Unit,
+        total = total,
+        completed = completed,
+        start = start,
+        visible = visible,
+        clearWhenFinished = clearWhenFinished,
+        speedEstimateDuration = speedEstimateDuration,
+        timeSource = timeSource,
+        maker = maker
+    )
+}
+
+/**
+ * Create an animator that runs this animation synchronously.
+ *
+ * Use [execute] to run the animation on a background thread.
+ *
+ * ### Example
+ *
+ * ```
+ * val animation = terminal.animation{ ... }.animateOnThread(terminal)
+ * animation.execute()
+ * ```
+ */
+inline fun Animation.animateOnThread(
+    fps: Int = 30,
+    crossinline finished: () -> Boolean = { false },
+): BlockingAnimator {
+    return asRefreshable(fps, finished).animateOnThread(terminal)
+}
+
+/**
+ * Create an animator that runs this animation synchronously.
+ *
+ * Use [execute] to run the animation on a background thread.
+ *
+ * ### Example
+ *
+ * ```
+ * val animator = animation.animateOnThread(terminal)
+ * animator.execute()
+ * ```
+ */
+fun RefreshableAnimation.animateOnThread(terminal: Terminal): BlockingAnimator {
+    return BaseBlockingAnimator(terminal, this)
+}
+
+/**
+ * Create an animator that runs this animation synchronously.
+ *
+ * Use [execute] to run the animation on a background thread.
+ *
+ * ### Example
+ *
+ * ```
+ * val animator = animation.animateOnThread()
+ * animator.execute()
+ * ```
+ */
+fun MultiProgressBarAnimation.animateOnThread(): ThreadProgressAnimator {
+    return ThreadProgressAnimatorImpl(this, animateOnThread(terminal))
+}
+
+/**
+ * Run the animation in a background thread on an [executor].
+ *
+ * @return a [Future] that can be used to cancel the animation.
+ */
+fun BlockingAnimator.execute(
+    executor: ExecutorService = Executors.newSingleThreadExecutor(DaemonThreadFactory()),
+): Future<*> {
+    return executor.submit(::runBlocking)
+}
+
+private class DaemonThreadFactory : ThreadFactory {
+    override fun newThread(r: Runnable): Thread = Thread(r).also {
+        it.name = "${it.name}-mordant-animator"
+        it.isDaemon = true
+    }
+}
+
+private class ThreadProgressTaskAnimatorImpl(
+    private val task: ProgressTask,
+    private val animator: BlockingAnimator,
+) : ThreadProgressTaskAnimator, BlockingAnimator by animator, ProgressTask by task {
+    override val finished: Boolean get() = animator.finished
+}
+
+private class ThreadProgressAnimatorImpl(
+    private val animation: ProgressBarAnimation,
+    private val animator: BlockingAnimator,
+) : ThreadProgressAnimator, BlockingAnimator by animator, ProgressBarAnimation by animation {
+    override fun refresh(refreshAll: Boolean) = animator.refresh(refreshAll)
+    override val finished: Boolean get() = animator.finished
+}
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 d05feeac1..6afd857d5 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
@@ -8,8 +8,23 @@ import com.github.ajalt.mordant.internal.nativeimage.NativeImageWin32MppImpls
 import com.github.ajalt.mordant.terminal.*
 import java.lang.management.ManagementFactory
 import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
 
-private class JvmAtomicInt(initial: Int): MppAtomicInt {
+private class JvmAtomicRef(value: T) : MppAtomicRef {
+    private val ref = AtomicReference(value)
+    override val value: T
+        get() = ref.get()
+
+    override fun compareAndSet(expected: T, newValue: T): Boolean {
+        return ref.compareAndSet(expected, newValue)
+    }
+
+    override fun getAndSet(newValue: T): T {
+        return ref.getAndSet(newValue)
+    }
+}
+
+private class JvmAtomicInt(initial: Int) : MppAtomicInt {
     private val backing = AtomicInteger(initial)
     override fun getAndIncrement(): Int {
         return backing.getAndIncrement()
@@ -25,6 +40,7 @@ private class JvmAtomicInt(initial: Int): MppAtomicInt {
 }
 
 internal actual fun MppAtomicInt(initial: Int): MppAtomicInt = JvmAtomicInt(initial)
+internal actual fun  MppAtomicRef(value: T): MppAtomicRef = JvmAtomicRef(value)
 
 internal actual fun getEnv(key: String): String? = System.getenv(key)
 
@@ -60,14 +76,16 @@ internal actual fun readLineOrNullMpp(hideInput: Boolean): String? {
     return readlnOrNull()
 }
 
-internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor = JvmTerminalCursor(terminal)
+internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor {
+    return JvmTerminalCursor(terminal)
+}
 
 private class JvmTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) {
     private var shutdownHook: Thread? = null
-    private val lock = Any()
+    private val cursorLock = Any()
 
     override fun show() {
-        synchronized(lock) {
+        synchronized(cursorLock) {
             shutdownHook?.let { hook ->
                 Runtime.getRuntime().removeShutdownHook(hook)
             }
@@ -77,7 +95,7 @@ private class JvmTerminalCursor(terminal: Terminal) : PrintTerminalCursor(termin
 
     override fun hide(showOnExit: Boolean) {
         if (showOnExit) {
-            synchronized(lock) {
+            synchronized(cursorLock) {
                 if (shutdownHook == null) {
                     shutdownHook = Thread { super.show() }
                     Runtime.getRuntime().addShutdownHook(shutdownHook)
@@ -88,16 +106,18 @@ private class JvmTerminalCursor(terminal: Terminal) : PrintTerminalCursor(termin
     }
 }
 
+private val printRequestLock = Any()
+
 internal actual fun sendInterceptedPrintRequest(
     request: PrintRequest,
     terminalInterface: TerminalInterface,
     interceptors: List,
-) {
-    terminalInterface.completePrintRequest(interceptors.fold(request) { acc, it -> it.intercept(acc) })
+) = synchronized(printRequestLock) {
+    terminalInterface.completePrintRequest(interceptors.fold(request) { acc, it ->
+        it.intercept(acc)
+    })
 }
 
-internal actual inline fun synchronizeJvm(lock: Any, block: () -> Unit) = synchronized(lock, block)
-
 private val impls: MppImpls = System.getProperty("os.name").let { os ->
     try {
         // Inlined version of ImageInfo.inImageCode()
@@ -105,7 +125,7 @@ private val impls: MppImpls = System.getProperty("os.name").let { os ->
         val isNativeImage = imageCode == "buildtime" || imageCode == "runtime"
         when {
             isNativeImage && os.startsWith("Windows") -> NativeImageWin32MppImpls()
-            isNativeImage && (os == "Linux"  || os == "Mac OS X") -> NativeImagePosixMppImpls()
+            isNativeImage && (os == "Linux" || os == "Mac OS X") -> NativeImagePosixMppImpls()
             os.startsWith("Windows") -> JnaWin32MppImpls()
             os == "Linux" -> JnaLinuxMppImpls()
             os == "Mac OS X" -> JnaMacosMppImpls()
@@ -118,4 +138,5 @@ private val impls: MppImpls = System.getProperty("os.name").let { os ->
 
 internal actual fun stdoutInteractive(): Boolean = impls.stdoutInteractive()
 internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive()
-internal actual fun getTerminalSize(): Pair? = impls.getTerminalSize()
+internal actual fun getTerminalSize(): Size? = impls.getTerminalSize()
+internal actual val FAST_ISATTY: Boolean = true
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt
index 7c101334f..cdfe8f506 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt
@@ -5,7 +5,8 @@ internal interface MppImpls {
     fun stdoutInteractive(): Boolean
     fun stdinInteractive(): Boolean
     fun stderrInteractive(): Boolean
-    fun getTerminalSize(): Pair?
+    fun getTerminalSize(): Size?
+    fun fastIsTty(): Boolean = true
 }
 
 
@@ -14,5 +15,6 @@ internal class FallbackMppImpls : MppImpls {
     override fun stdoutInteractive(): Boolean = System.console() != null
     override fun stdinInteractive(): Boolean = System.console() != null
     override fun stderrInteractive(): Boolean = System.console() != null
-    override fun getTerminalSize(): Pair? = null
+    override fun getTerminalSize(): Size? = null
+    override fun fastIsTty(): Boolean = false
 }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt
index 8115ab39f..d9e29bd0d 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt
@@ -1,6 +1,7 @@
 package com.github.ajalt.mordant.internal.jna
 
 import com.github.ajalt.mordant.internal.MppImpls
+import com.github.ajalt.mordant.internal.Size
 import com.oracle.svm.core.annotate.Delete
 import com.sun.jna.Library
 import com.sun.jna.Native
@@ -50,12 +51,12 @@ internal class JnaLinuxMppImpls : MppImpls {
     override fun stdinInteractive(): Boolean = libC.isatty(STDIN_FILENO) == 1
     override fun stderrInteractive(): Boolean = libC.isatty(STDERR_FILENO) == 1
 
-    override fun getTerminalSize(): Pair? {
+    override fun getTerminalSize(): Size? {
         val size = PosixLibC.winsize()
         return if (libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) {
             null
         } else {
-            size.ws_col.toInt() to size.ws_row.toInt()
+            Size(width = size.ws_col.toInt(), height = size.ws_row.toInt())
         }
     }
 }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt
index efcdebfc5..a9d2666a0 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt
@@ -1,6 +1,7 @@
 package com.github.ajalt.mordant.internal.jna
 
 import com.github.ajalt.mordant.internal.MppImpls
+import com.github.ajalt.mordant.internal.Size
 import com.oracle.svm.core.annotate.Delete
 import com.sun.jna.*
 import java.io.IOException
@@ -52,7 +53,8 @@ internal class JnaMacosMppImpls : MppImpls {
     override fun stdinInteractive(): Boolean = libC.isatty(STDIN_FILENO) == 1
     override fun stderrInteractive(): Boolean = libC.isatty(STDERR_FILENO) == 1
 
-    override fun getTerminalSize(): Pair? {
+    override fun fastIsTty(): Boolean = false
+    override fun getTerminalSize(): Size? {
         // TODO: this seems to fail on macosArm64, use stty on mac for now
 //        val size = MacosLibC.winsize()
 //        return if (libC.ioctl(STDIN_FILENO, NativeLong(TIOCGWINSZ), size) < 0) {
@@ -64,11 +66,10 @@ internal class JnaMacosMppImpls : MppImpls {
     }
 
 
-
 }
 
 @Suppress("SameParameterValue")
-private fun getSttySize(timeoutMs: Long): Pair? {
+private fun getSttySize(timeoutMs: Long): Size? {
     val process = when {
         // Try running stty both directly and via env, since neither one works on all systems
         else -> runCommand("stty", "size") ?: runCommand("/usr/bin/env", "stty", "size")
@@ -97,8 +98,8 @@ private fun runCommand(vararg args: String): Process? {
     }
 }
 
-private fun parseSttySize(output: String): Pair? {
+private fun parseSttySize(output: String): Size? {
     val dimens = output.split(" ").mapNotNull { it.toIntOrNull() }
     if (dimens.size != 2) return null
-    return dimens[1] to dimens[0]
+    return Size(width = dimens[1], height = dimens[0])
 }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt
index 682237961..ede22c5c9 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt
@@ -1,6 +1,7 @@
 package com.github.ajalt.mordant.internal.jna
 
 import com.github.ajalt.mordant.internal.MppImpls
+import com.github.ajalt.mordant.internal.Size
 import com.oracle.svm.core.annotate.Delete
 import com.sun.jna.Library
 import com.sun.jna.Native
@@ -20,6 +21,7 @@ private interface WinKernel32Lib : Library {
         const val STD_OUTPUT_HANDLE = -11
         const val STD_ERROR_HANDLE = -12
     }
+
     class HANDLE : PointerType()
 
     @Structure.FieldOrder("X", "Y")
@@ -46,7 +48,13 @@ private interface WinKernel32Lib : Library {
         var Bottom: Short = 0
     }
 
-    @Structure.FieldOrder("dwSize", "dwCursorPosition", "wAttributes", "srWindow", "dwMaximumWindowSize")
+    @Structure.FieldOrder(
+        "dwSize",
+        "dwCursorPosition",
+        "wAttributes",
+        "srWindow",
+        "dwMaximumWindowSize"
+    )
     class CONSOLE_SCREEN_BUFFER_INFO : Structure() {
         @JvmField
         var dwSize: COORD? = null
@@ -74,7 +82,8 @@ private interface WinKernel32Lib : Library {
 
 @Delete
 internal class JnaWin32MppImpls : MppImpls {
-    private val kernel = Native.load("kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS);
+    private val kernel =
+        Native.load("kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS);
     private val stdoutHandle = kernel.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE)
     private val stdinHandle = kernel.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE)
     private val stderrHandle = kernel.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE)
@@ -90,11 +99,11 @@ internal class JnaWin32MppImpls : MppImpls {
         return kernel.GetConsoleMode(stderrHandle, IntByReference())
     }
 
-    override fun getTerminalSize(): Pair? {
+    override fun getTerminalSize(): Size? {
         val csbi = WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO()
         if (!kernel.GetConsoleScreenBufferInfo(stdoutHandle, csbi)) {
             return null
         }
-        return csbi.srWindow?.run { Right - Left + 1 to Bottom - Top + 1 }
+        return csbi.srWindow?.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) }
     }
 }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt
index 0b677ceeb..60c39f80d 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt
@@ -1,6 +1,7 @@
 package com.github.ajalt.mordant.internal.nativeimage
 
 import com.github.ajalt.mordant.internal.MppImpls
+import com.github.ajalt.mordant.internal.Size
 import org.graalvm.nativeimage.Platform
 import org.graalvm.nativeimage.Platforms
 import org.graalvm.nativeimage.StackValue
@@ -57,12 +58,12 @@ internal class NativeImagePosixMppImpls : MppImpls {
     override fun stdinInteractive() = PosixLibC.isatty(PosixLibC.STDIN_FILENO())
     override fun stderrInteractive() = PosixLibC.isatty(PosixLibC.STDERR_FILENO())
 
-    override fun getTerminalSize(): Pair? {
+    override fun getTerminalSize(): Size? {
         val size = StackValue.get(PosixLibC.winsize::class.java)
         return if (PosixLibC.ioctl(0, PosixLibC.TIOCGWINSZ(), size) < 0) {
             null
         } else {
-            size.ws_col.toInt() to size.ws_row.toInt()
+            Size(width = size.ws_col.toInt(), height = size.ws_row.toInt())
         }
     }
 }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt
index 83eda701e..33ace7b5a 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt
@@ -1,6 +1,7 @@
 package com.github.ajalt.mordant.internal.nativeimage
 
 import com.github.ajalt.mordant.internal.MppImpls
+import com.github.ajalt.mordant.internal.Size
 import org.graalvm.nativeimage.Platform
 import org.graalvm.nativeimage.Platforms
 import org.graalvm.nativeimage.StackValue
@@ -79,13 +80,13 @@ internal class NativeImageWin32MppImpls : MppImpls {
         return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java))
     }
 
-    override fun getTerminalSize(): Pair? {
+    override fun getTerminalSize(): Size? {
         val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java)
         val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE())
         return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) {
             null
         } else {
-            csbi.Right - csbi.Left + 1 to csbi.Bottom - csbi.Top + 1
+            Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1)
         }
     }
 
diff --git a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/DeprecatedProgressAnimationTest.kt b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/DeprecatedProgressAnimationTest.kt
new file mode 100644
index 000000000..3b83f8627
--- /dev/null
+++ b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/DeprecatedProgressAnimationTest.kt
@@ -0,0 +1,100 @@
+package com.github.ajalt.mordant.animation
+
+import com.github.ajalt.mordant.internal.CSI
+import com.github.ajalt.mordant.rendering.Theme
+import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.terminal.TerminalRecorder
+import com.github.ajalt.mordant.test.RenderingTest
+import com.github.ajalt.mordant.test.normalizedOutput
+import io.kotest.matchers.shouldBe
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TestTimeSource
+
+class DeprecatedProgressAnimationTest : RenderingTest() {
+    private val now = TestTimeSource()
+    private val vt = TerminalRecorder(width = 56)
+    private val t = Terminal(
+        theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
+        terminalInterface = vt
+    )
+
+    @Test
+    fun throttling() {
+        val pt = t.progressAnimation(now) {
+            textFrameRate = 1
+            padding = 0
+            speed()
+            text("|")
+            timeRemaining()
+        }
+
+        pt.update(0, 1000)
+        vt.normalizedOutput() shouldBe " ---.-it/s|eta -:--:--"
+
+        now += 0.5.seconds
+        vt.clearOutput()
+        pt.update(40)
+        vt.normalizedOutput() shouldBe " ---.-it/s|eta -:--:--"
+
+        now += 0.1.seconds
+        vt.clearOutput()
+        pt.update()
+        vt.normalizedOutput() shouldBe " ---.-it/s|eta -:--:--"
+
+        now += 0.4.seconds
+        vt.clearOutput()
+        pt.update()
+        vt.normalizedOutput() shouldBe "  40.0it/s|eta 0:00:24"
+
+        now += 0.9.seconds
+        vt.clearOutput()
+        pt.update()
+        vt.normalizedOutput() shouldBe "  40.0it/s|eta 0:00:24"
+    }
+
+    @Test
+    fun animation() {
+        val pt = t.progressAnimation(now) {
+            textFrameRate = 1
+            padding = 0
+            text("text.txt")
+            text("|")
+            percentage()
+            text("|")
+            progressBar()
+            text("|")
+            completed()
+            text("|")
+            speed()
+            text("|")
+            timeRemaining()
+        }
+        pt.update(0, 100)
+        vt.normalizedOutput() shouldBe "text.txt|  0%|......|       0/100| ---.-it/s|eta -:--:--"
+
+        now += 10.0.seconds
+        vt.clearOutput()
+        pt.update(40)
+        vt.normalizedOutput() shouldBe "text.txt| 40%|##>...|      40/100|   4.0it/s|eta 0:00:15"
+
+        now += 10.0.seconds
+        vt.clearOutput()
+        pt.update()
+        vt.normalizedOutput() shouldBe "text.txt| 40%|##>...|      40/100|   2.0it/s|eta 0:00:30"
+
+        now += 10.0.seconds
+        vt.clearOutput()
+        pt.updateTotal(200)
+        vt.normalizedOutput() shouldBe "text.txt| 20%|#>....|      40/200|   1.3it/s|eta 0:02:00"
+
+        vt.clearOutput()
+        pt.restart()
+        vt.normalizedOutput() shouldBe "text.txt|  0%|......|       0/200| ---.-it/s|eta -:--:--"
+
+        vt.clearOutput()
+        pt.clear()
+        val moves = t.cursor.getMoves { startOfLine(); clearScreenAfterCursor() }
+        vt.output() shouldBe "$moves$CSI?25h"
+    }
+}
diff --git a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt
deleted file mode 100644
index f097e3b89..000000000
--- a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-package com.github.ajalt.mordant.animation
-
-import com.github.ajalt.mordant.internal.CSI
-import com.github.ajalt.mordant.rendering.Theme
-import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.terminal.TerminalRecorder
-import com.github.ajalt.mordant.test.RenderingTest
-import io.kotest.matchers.shouldBe
-import java.util.concurrent.TimeUnit
-import kotlin.test.Test
-
-class ProgressAnimationTest : RenderingTest() {
-    var now = 0.0
-
-    @Test
-    fun throttling() {
-        val vt = TerminalRecorder()
-        val t = Terminal(terminalInterface = vt)
-        val pt = t.progressAnimation {
-            timeSource = { (now * TimeUnit.SECONDS.toNanos(1)).toLong() }
-            textFrameRate = 1
-            padding = 0
-            speed()
-            text("|")
-            timeRemaining()
-        }
-
-        pt.update(0, 1000)
-        now = 0.5
-        vt.clearOutput()
-        pt.update(40)
-        vt.normalizedBuffer() shouldBe " ---.-it/s|eta -:--:--"
-
-        now = 0.6
-        vt.clearOutput()
-        pt.update()
-        vt.normalizedBuffer() shouldBe " ---.-it/s|eta -:--:--"
-
-        now = 1.0
-        vt.clearOutput()
-        pt.update()
-        vt.normalizedBuffer() shouldBe "  40.0it/s|eta 0:00:24"
-
-        now = 1.9
-        vt.clearOutput()
-        pt.update()
-        vt.normalizedBuffer() shouldBe "  40.0it/s|eta 0:00:24"
-    }
-
-    @Test
-    fun animation() {
-        val vt = TerminalRecorder(width = 56)
-        val t = Terminal(
-            theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
-            terminalInterface = vt
-        )
-        val pt = t.progressAnimation {
-            timeSource = { (now * TimeUnit.SECONDS.toNanos(1)).toLong() }
-            padding = 0
-            text("text.txt")
-            text("|")
-            percentage()
-            text("|")
-            progressBar()
-            text("|")
-            completed()
-            text("|")
-            speed()
-            text("|")
-            timeRemaining()
-        }
-        pt.update(0, 100)
-        vt.normalizedBuffer() shouldBe "text.txt|  0%|......|   0.0/100.0| ---.-it/s|eta -:--:--"
-
-        now = 10.0
-        vt.clearOutput()
-        pt.update(40)
-        vt.normalizedBuffer() shouldBe "text.txt| 40%|##>...|  40.0/100.0|   4.0it/s|eta 0:00:15"
-
-        now = 20.0
-        vt.clearOutput()
-        pt.update()
-        vt.normalizedBuffer() shouldBe "text.txt| 40%|##>...|  40.0/100.0|   2.0it/s|eta 0:00:30"
-
-        now = 30.0
-        vt.clearOutput()
-        pt.updateTotal(200)
-        vt.normalizedBuffer() shouldBe "text.txt| 20%|#>....|  40.0/200.0|   1.3it/s|eta 0:02:00"
-
-        vt.clearOutput()
-        pt.restart()
-        vt.normalizedBuffer() shouldBe "text.txt|  0%|......|   0.0/200.0| ---.-it/s|eta -:--:--"
-
-        vt.clearOutput()
-        pt.clear()
-        vt.normalizedBuffer() shouldBe ""
-    }
-
-    private fun TerminalRecorder.normalizedBuffer(): String {
-        return output().substringAfter("${CSI}0J").substringAfter("${CSI}1A").trimEnd()
-    }
-}
diff --git a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt
new file mode 100644
index 000000000..46b2248e5
--- /dev/null
+++ b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt
@@ -0,0 +1,46 @@
+package com.github.ajalt.mordant.animation.progress
+
+import com.github.ajalt.mordant.animation.textAnimation
+import com.github.ajalt.mordant.internal.CSI
+import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.terminal.TerminalRecorder
+import com.github.ajalt.mordant.widgets.progress.completed
+import com.github.ajalt.mordant.widgets.progress.progressBarLayout
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import kotlin.test.Test
+
+private const val HIDE_CURSOR = "$CSI?25l"
+private const val SHOW_CURSOR = "$CSI?25h"
+
+class ThreadAnimatorTest {
+    private val vt = TerminalRecorder(width = 56)
+    private val t = Terminal(terminalInterface = vt)
+
+    @Test
+    fun `unit animator`() {
+        var i = 1
+        val a = t.textAnimation { "${i++}" }.animateOnThread(fps = 10000) { i > 2 }
+        a.runBlocking()
+        vt.output() shouldBe "${HIDE_CURSOR}1\r2\r3"
+        vt.clearOutput()
+        a.stop()
+        vt.output() shouldBe "\n$SHOW_CURSOR"
+        vt.clearOutput()
+    }
+
+    @Test
+    fun `multi progress animator`() {
+        val layout = progressBarLayout { completed(fps = 100) }
+        val animation = MultiProgressBarAnimation(t).animateOnThread()
+        val task1 = animation.addTask(layout, total = 10)
+        val task2 = animation.addTask(layout, total = 10)
+        task1.advance(10)
+        task2.advance(10)
+        animation.runBlocking()
+        vt.output().shouldContain(" 10/10\n       10/10")
+    }
+}
+
diff --git a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index 83836a0bd..41b9dee9d 100644
--- a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -9,13 +9,13 @@ import platform.posix.ioctl
 import platform.posix.winsize
 
 @OptIn(ExperimentalForeignApi::class)
-internal actual fun getTerminalSize(): Pair? {
+internal actual fun getTerminalSize(): Size? {
     return memScoped {
         val size = alloc()
         if (ioctl(STDIN_FILENO, TIOCGWINSZ.toULong(), size) < 0) {
             null
         } else {
-            size.ws_col.toInt() to size.ws_row.toInt()
+            Size(width=size.ws_col.toInt(), height=size.ws_row.toInt())
         }
     }
 }
diff --git a/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index 95cc2cac8..9dc05b62d 100644
--- a/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -9,13 +9,13 @@ import platform.posix.ioctl
 import platform.posix.winsize
 
 @OptIn(ExperimentalForeignApi::class)
-internal actual fun getTerminalSize(): Pair? {
+internal actual fun getTerminalSize(): Size? {
     return memScoped {
         val size = alloc()
         if (ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) {
             null
         } else {
-            size.ws_col.toInt() to size.ws_row.toInt()
+            Size(width = size.ws_col.toInt(), height = size.ws_row.toInt())
         }
     }
 }
diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index f80ceab50..35406c5a8 100644
--- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -7,7 +7,7 @@ import platform.windows.*
 
 
 // https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo
-internal actual fun getTerminalSize(): Pair? = memScoped {
+internal actual fun getTerminalSize(): Size? = memScoped {
     val csbi = alloc()
     val stdoutHandle = GetStdHandle(STD_OUTPUT_HANDLE)
     if (stdoutHandle == INVALID_HANDLE_VALUE) {
@@ -17,7 +17,7 @@ internal actual fun getTerminalSize(): Pair? = memScoped {
     if (GetConsoleScreenBufferInfo(stdoutHandle, csbi.ptr) == 0) {
         return@memScoped null
     }
-    csbi.srWindow.run { Right - Left + 1 to Bottom - Top + 1 }
+    csbi.srWindow.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) }
 }
 
 // https://docs.microsoft.com/en-us/windows/console/setconsolemode
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 1cab64cbd..fcc1d8f90 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
@@ -9,6 +9,19 @@ import kotlin.concurrent.AtomicInt
 import kotlin.concurrent.AtomicReference
 import kotlin.experimental.ExperimentalNativeApi
 
+private class NativeAtomicRef(value: T) : MppAtomicRef {
+    private val ref = AtomicReference(value)
+    override val value: T
+        get() = ref.value
+
+    override fun compareAndSet(expected: T, newValue: T): Boolean {
+        return ref.compareAndSet(expected, newValue)
+    }
+
+    override fun getAndSet(newValue: T): T {
+        return ref.getAndSet(newValue)
+    }
+}
 
 private class NativeAtomicInt(initial: Int) : MppAtomicInt {
     private val backing = AtomicInt(initial)
@@ -26,6 +39,7 @@ private class NativeAtomicInt(initial: Int) : MppAtomicInt {
 }
 
 internal actual fun MppAtomicInt(initial: Int): MppAtomicInt = NativeAtomicInt(initial)
+internal actual fun  MppAtomicRef(value: T): MppAtomicRef = NativeAtomicRef(value)
 
 internal actual fun runningInIdeaJavaAgent(): Boolean = false
 
@@ -120,15 +134,23 @@ private class NativeTerminalCursor(terminal: Terminal) : PrintTerminalCursor(ter
     }
 }
 
+private val printRequestLock = AtomicInt(0)
 
 internal actual fun sendInterceptedPrintRequest(
     request: PrintRequest,
     terminalInterface: TerminalInterface,
     interceptors: List,
 ) {
-    terminalInterface.completePrintRequest(
-        interceptors.fold(request) { acc, it -> it.intercept(acc) }
-    )
+    while(printRequestLock.compareAndSet(0, 1)) {
+        // spin until we get the lock
+    }
+    try {
+        terminalInterface.completePrintRequest(
+            interceptors.fold(request) { acc, it -> it.intercept(acc) }
+        )
+    } finally {
+        printRequestLock.value = 0
+    }
 }
 
-internal actual inline fun synchronizeJvm(lock: Any, block: () -> Unit) = block()
+internal actual val FAST_ISATTY: Boolean = true
diff --git a/samples/hexviewer/README.md b/samples/hexviewer/README.md
new file mode 100644
index 000000000..d0326c04c
--- /dev/null
+++ b/samples/hexviewer/README.md
@@ -0,0 +1,9 @@
+# Hexviewer Sample
+
+This sample is a hex viewer like `xxd` which uses mordant for color and layout.
+
+```
+$ hexviewer picture.png
+```
+
+![](example.png)
diff --git a/samples/hexviewer/build.gradle.kts b/samples/hexviewer/build.gradle.kts
new file mode 100644
index 000000000..2fecd316d
--- /dev/null
+++ b/samples/hexviewer/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+    id("mordant-mpp-sample-conventions")
+}
+
+kotlin {
+    sourceSets {
+        commonMain.dependencies {
+            implementation("com.squareup.okio:okio:3.7.0")
+        }
+    }
+}
diff --git a/samples/hexviewer/example.png b/samples/hexviewer/example.png
new file mode 100644
index 000000000..b8e113ba6
Binary files /dev/null and b/samples/hexviewer/example.png differ
diff --git a/samples/hexviewer/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/hexviewer/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
new file mode 100644
index 000000000..46c2e61e3
--- /dev/null
+++ b/samples/hexviewer/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
@@ -0,0 +1,81 @@
+package com.github.ajalt.mordant.samples
+
+import com.github.ajalt.mordant.rendering.BorderType
+import com.github.ajalt.mordant.rendering.TextStyle
+import com.github.ajalt.mordant.rendering.TextStyles.*
+import com.github.ajalt.mordant.table.Borders
+import com.github.ajalt.mordant.table.table
+import com.github.ajalt.mordant.terminal.Terminal
+import okio.FileSystem
+import okio.Path.Companion.toPath
+
+
+fun main(args: Array) {
+    val terminal = Terminal()
+    if (args.size != 1) {
+        terminal.danger("Usage: hexviewer ")
+        return
+    }
+    val path = args[0].toPath()
+    if (!fileSystem().exists(path)) {
+        terminal.danger("File not found: $path")
+        return
+    }
+    
+    // The characters to show for each byte value
+    val display = "·␁␂␃␄␅␆␇␈␉␊␋␌␍␎␏␐␑␒␓␔␕␖␗␘␙␚␛␜␝␞␟" +
+            " !\"#$%&'()*+,-./0123456789:;<=>?@" +
+            "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
+            "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~␡"
+
+    // The text style for each byte value
+    val styles = List(256) {
+        when (it) {
+            0 -> TextStyle(dim = true)
+            in 1..31, 127 -> terminal.theme.danger
+            in 32..126 -> TextStyle()
+            else -> terminal.theme.warning
+        }
+    }
+
+    // 4 chars per octet, -20 for borders + address
+    val w = (terminal.info.width - 20) / 4
+    // round down to nearest multiple of 8
+    val octetsPerRow = (w - w % 8).coerceAtLeast(1)
+
+    val bytes = fileSystem().read(path) { readByteArray() }
+
+    val table = table {
+        cellBorders = Borders.LEFT_RIGHT
+        tableBorders = Borders.ALL
+        borderType = BorderType.ROUNDED
+        column(0) { style = terminal.theme.info }
+
+        body {
+            for (addr in bytes.indices step octetsPerRow) {
+                val hex = StringBuilder()
+                val ascii = StringBuilder()
+                for (i in addr..<(addr + octetsPerRow).coerceAtMost(bytes.size)) {
+                    val byte = bytes[i].toInt() and 0xff
+                    if (i > addr) {
+                        if (i % 8 == 0) hex.append("┆") else hex.append(" ")
+                    }
+                    val s = styles[byte]
+                    hex.append(s(byte.toString(16).padStart(2, '0')))
+                    ascii.append(s(display.getOrElse(byte) { '·' }.toString()))
+                }
+                row(
+                    "0x" + addr.toString(16).padStart(8, '0'),
+                    hex.toString(),
+                    ascii.toString()
+                )
+            }
+        }
+
+    }
+    terminal.println(table)
+}
+
+private fun fileSystem(): FileSystem {
+    return FileSystem.SYSTEM // Intellij doesn't recognize this, but the compiler does
+}
diff --git a/samples/progress/build.gradle.kts b/samples/progress/build.gradle.kts
index 8e682dfef..e3eebd377 100644
--- a/samples/progress/build.gradle.kts
+++ b/samples/progress/build.gradle.kts
@@ -1,3 +1,11 @@
 plugins {
     id("mordant-jvm-sample-conventions")
 }
+
+kotlin {
+    sourceSets {
+        jvmMain.dependencies {
+            implementation(project(":extensions:mordant-coroutines"))
+        }
+    }
+}
diff --git a/samples/progress/src/jvmMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/progress/src/jvmMain/kotlin/com/github/ajalt/mordant/samples/main.kt
index 6e2f0055e..e3524dd22 100644
--- a/samples/progress/src/jvmMain/kotlin/com/github/ajalt/mordant/samples/main.kt
+++ b/samples/progress/src/jvmMain/kotlin/com/github/ajalt/mordant/samples/main.kt
@@ -1,34 +1,94 @@
 package com.github.ajalt.mordant.samples
 
-import com.github.ajalt.mordant.animation.progressAnimation
+import com.github.ajalt.mordant.animation.coroutines.animateInCoroutine
+import com.github.ajalt.mordant.animation.progress.MultiProgressBarAnimation
+import com.github.ajalt.mordant.animation.progress.advance
+import com.github.ajalt.mordant.animation.progress.removeTask
+import com.github.ajalt.mordant.rendering.TextAlign
 import com.github.ajalt.mordant.rendering.TextColors.brightBlue
+import com.github.ajalt.mordant.rendering.TextColors.magenta
+import com.github.ajalt.mordant.rendering.TextStyles.bold
+import com.github.ajalt.mordant.rendering.TextStyles.dim
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.widgets.Spinner
+import com.github.ajalt.mordant.widgets.progress.*
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.random.Random
 
-fun main() {
+suspend fun main() = coroutineScope {
     val terminal = Terminal()
-
-    val progress = terminal.progressAnimation {
-        spinner(Spinner.Dots(brightBlue))
-        text("my-file.bin")
+    val modules = listOf(
+        ":mordant",
+        ":extensions:mordant-coroutines",
+        ":extensions:mordant-native",
+        ":samples:progress",
+        ":samples:tables",
+        ":samples:widgets",
+        ":samples:terminal-themes",
+        ":samples:markdown",
+    )
+    val overallLayout = progressBarContextLayout(alignColumns = false) {
+        progressBar(width = 20)
         percentage()
-        progressBar()
-        completed()
-        speed("B/s")
-        timeRemaining()
+        text { (terminal.theme.success + bold)(context) }
+        timeElapsed(compact = false)
     }
+    val taskLayout = progressBarContextLayout {
+        text(fps = animationFps, align = TextAlign.LEFT) { "〉$context" }
+    }
+
+    val progress = MultiProgressBarAnimation(terminal).animateInCoroutine()
+    val overall = progress.addTask(overallLayout, "INITIALIZING", total = 100)
+    launch { progress.execute() }
+    val task1 = progress.addTask(taskLayout, bold("Evaluate settings"))
+    delay(200)
+
 
-    progress.start()
+    overall.update { context = "CONFIGURING" }
+    task1.update { context = "Resolve dependencies for buildSrc" }
+    val task2 = progress.addTask(taskLayout, dim("IDLE"))
+    val task3 = progress.addTask(taskLayout, dim("IDLE"))
+    val tasks = listOf(task1, task2, task3)
+    delay(200)
 
-    // Sleep for a few seconds to show the indeterminate state
-    Thread.sleep(5000)
+    overall.update { context = "EXECUTING" }
+    repeat(5) {
+        for (module in modules) {
+            tasks[Random.nextInt(tasks.size)].update { context = module }
+            overall.advance()
+            delay(100)
+        }
+    }
+
+    overall.update { context = "EXECUTING" }
+    tasks.forEach { progress.removeTask(it) }
 
-    // Update the progress as the download progresses
-    progress.updateTotal(3_000_000_000)
-    repeat(200) {
-        progress.advance(15_000_000)
-        Thread.sleep(100)
+    val dlLayout = progressBarContextLayout {
+        spinner(Spinner.Dots(brightBlue))
+        marquee(width = 15) { terminal.theme.warning(context) }
+        percentage()
+        progressBar()
+        completed(style = terminal.theme.success)
+        speed("B/s", style = terminal.theme.info)
+        timeRemaining(style = magenta)
     }
 
-    progress.stop()
+    val download1 = progress.addTask(dlLayout, "ubuntu-desktop-amd64.iso", total = 3_000_000_000)
+    val download2 = progress.addTask(dlLayout, "fedora-kde-live-x86_64.iso", total = 2_500_000_000)
+    val download3 = progress.addTask(dlLayout, "archlinux-x86_64.iso")
+    val downloads = listOf(download1, download2, download3)
+    while (!progress.finished) {
+        if (!download1.finished) download1.advance(15_000_000)
+        if (!download2.finished) download2.advance(10_000_000)
+        if (download1.completed > 1_000_000_000) {
+            if (!download3.finished) download3.update {
+                total = 1_000_000_000
+                completed += 6_000_000
+            }
+        }
+        overall.update { completed = 40 + 20 * downloads.count { it.finished }.toLong() }
+        delay(50)
+    }
 }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8b070d7d8..7c7b9ab31 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -2,7 +2,9 @@ rootProject.name = "mordant"
 
 include(
     "mordant",
+    "extensions:mordant-coroutines",
     "samples:detection",
+    "samples:hexviewer",
     "samples:markdown",
     "samples:progress",
     "samples:table",
diff --git a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
index 10cc87229..36233968a 100644
--- a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
+++ b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
@@ -1,23 +1,28 @@
 package com.github.ajalt.mordant.graalvm
 
-import com.github.ajalt.mordant.animation.progressAnimation
+import com.github.ajalt.mordant.animation.progress.animateOnThread
+import com.github.ajalt.mordant.animation.progress.execute
 import com.github.ajalt.mordant.markdown.Markdown
 import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.rendering.TextStyles.bold
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.terminal.TerminalRecorder
+import com.github.ajalt.mordant.widgets.progress.progressBar
+import com.github.ajalt.mordant.widgets.progress.progressBarLayout
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Test
+import java.util.concurrent.TimeUnit
 
 class GraalSmokeTest {
     @Test
     fun `progress animation test`() {
         // Just make sure it doesn't crash, exact output is verified in the normal test suite
         val t = Terminal(interactive = true, ansiLevel = AnsiLevel.TRUECOLOR)
-        val animation = t.progressAnimation { progressBar() }
-        animation.start()
+        val animation = progressBarLayout { progressBar() }.animateOnThread(t, total = 1)
+        val future = animation.execute()
         Thread.sleep(100)
-        animation.clear()
+        animation.update { completed = 1 }
+        future.get(100, TimeUnit.MILLISECONDS)
     }
 
     @Test
diff --git a/test/proguard/src/main/kotlin/R8SmokeTest.kt b/test/proguard/src/main/kotlin/R8SmokeTest.kt
index 017cca62b..c06a1cf42 100644
--- a/test/proguard/src/main/kotlin/R8SmokeTest.kt
+++ b/test/proguard/src/main/kotlin/R8SmokeTest.kt
@@ -1,9 +1,12 @@
 package com.github.ajalt.mordant.main
 
-import com.github.ajalt.mordant.animation.progressAnimation
+import com.github.ajalt.mordant.animation.progress.animateOnThread
+import com.github.ajalt.mordant.animation.progress.execute
 import com.github.ajalt.mordant.markdown.Markdown
 import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.widgets.progress.progressBar
+import com.github.ajalt.mordant.widgets.progress.progressBarLayout
 
 fun main(args: Array) {
     // make sure that the terminal detection doesn't crash.
@@ -11,9 +14,9 @@ fun main(args: Array) {
 
     // make sure animations and markdown don't crash.
     val t = Terminal(interactive = true, ansiLevel = AnsiLevel.TRUECOLOR)
-    val animation = t.progressAnimation { progressBar() }
-    animation.start()
+    val animation = progressBarLayout { progressBar() }.animateOnThread(t, total = 1)
+    animation.execute()
     t.print(Markdown("- Your args: **${args.asList()}**"))
     Thread.sleep(100)
-    animation.clear()
+    animation.update { completed = 1 }
 }