Repository: ajalt/mordant
Pull request #52 (2022-02-27 18:43:58)

Updates for next version


Patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index ce787f9e..cd1e0ba0 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -26,7 +26,7 @@ jobs:
       - uses: actions/setup-java@v1
         with:
           java-version: 11
-      - uses: eskatos/gradle-command-action@v2
+      - uses: gradle/gradle-build-action@v2
         with:
           arguments: :mordant:jvmTest :mordant:jsTest ${{matrix.NATIVE_TEST_TASK}} --stacktrace
       - name: Bundle the build report
@@ -49,7 +49,7 @@ jobs:
         with:
           java-version: 11
       - name: Deploy to sonatype
-        uses: eskatos/gradle-command-action@v2
+        uses: gradle/gradle-build-action@v2
         with:
           arguments: :mordant:publish -PinferVersion=true
   deploy-windows:
@@ -63,7 +63,7 @@ jobs:
         with:
           java-version: 11
       - name: Deploy to sonatype
-        uses: eskatos/gradle-command-action@v2
+        uses: gradle/gradle-build-action@v2
         with:
           arguments: :mordant:publishMingwX64PublicationToMavenRepository -PinferVersion=true
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a463d4b..4a80c180 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
 # Changelog
 
+# Unreleased
+### Added
+- Progress bars and other single-line animations are now supported in the IntelliJ console [(#49)](https://github.com/ajalt/mordant/issues/49)
+- Added `bottomTitle` to `Panel`
+- `Terminal.forStdErr` for printing to stderr rather than stdout
+- Add `macosArm64` target for native M1 macs
+
+### Changed
+- Update Kotlin to 1.6.10
+- *Breaking change*: Renamed `Table` and `Panel`'s `borderStyle` property to `borderType` and `borderTextStyle` to `borderStyle`
+- *Breaking change*: Renamed `TerminalInfo`'s `stdinInteractive` and `stdoutInteractive` to `inputInteractive` and `outputInteractive`, respectively
+
+### Fixed
+- Fix regression in clearing animations [(#48)](https://github.com/ajalt/mordant/issues/48)
+
 ## 2.0.0-beta4
 ### Added
 - `Spinner` widget that displays a looping animation
diff --git a/README.md b/README.md
index d4486e74..6e8671ee 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Mordant has:
 
 * Easy colorful ANSI output with automatic detection of terminal capabilities
 * Markdown rendering directly to the terminal
-* Widget for laying out terminal output, including lists, tables, panels, and more
+* Widgets for laying out terminal output, including lists, tables, panels, and more
 * Support for animating any widget, like progress bars and dashboards
 
 ##### This README documents Mordant 2.0, which is in beta. [You can read the docs for Mordant 1.0 here.](https://github.com/ajalt/mordant/blob/caec61d9ae667431cfe07e12eb426b005ee2cf06/README.md)
diff --git a/build.gradle.kts b/build.gradle.kts
index ea993ac9..19beabea 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,7 +2,7 @@ import org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask
 import java.io.ByteArrayOutputStream
 
 plugins {
-    kotlin("jvm") version "1.5.31"
+    kotlin("jvm") version "1.6.10"
 }
 
 val VERSION_NAME: String by project
diff --git a/gradle.properties b/gradle.properties
index 575f9263..5d0494ab 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,3 +1,8 @@
 VERSION_NAME=2.0.0-beta4
 
 kotlin.mpp.stability.nowarn=true
+
+# Enable hierarchical MPP project structure
+kotlin.mpp.enableGranularSourceSetsMetadata=true
+kotlin.native.enableDependencyPropagation=false
+kotlin.mpp.enableCompatibilityMetadataVariant=true
diff --git a/mordant/build.gradle.kts b/mordant/build.gradle.kts
index f4d450b0..4ce056e7 100644
--- a/mordant/build.gradle.kts
+++ b/mordant/build.gradle.kts
@@ -11,12 +11,12 @@ plugins {
     kotlin("multiplatform")
     id("maven-publish")
     id("signing")
-    id("org.jetbrains.dokka") version "1.5.31"
+    id("org.jetbrains.dokka") version "1.6.0"
 }
 
 buildscript {
     dependencies {
-        classpath("org.jetbrains.dokka:dokka-base:1.5.31")
+        classpath("org.jetbrains.dokka:dokka-base:1.6.0")
     }
 }
 
@@ -28,14 +28,15 @@ kotlin {
     }
 
     macosX64()
+    macosArm64()
     linuxX64()
     mingwX64()
 
     sourceSets {
         all {
             with(languageSettings) {
-                languageVersion = "1.5"
-                apiVersion = "1.5"
+                languageVersion = "1.6"
+                apiVersion = "1.6"
                 optIn("kotlin.RequiresOptIn")
             }
         }
@@ -43,19 +44,29 @@ kotlin {
         val commonMain by getting {
             dependsOn(gen)
             dependencies {
-                api("com.github.ajalt.colormath:colormath:3.1.1")
-                implementation("org.jetbrains:markdown:0.2.4")
+                api("com.github.ajalt.colormath:colormath:3.2.0")
+                implementation("org.jetbrains:markdown:0.3.1")
             }
         }
         val commonTest by getting {
             dependencies {
                 implementation(kotlin("test"))
-                implementation("io.kotest:kotest-assertions-core:4.6.3")
+                implementation("io.kotest:kotest-assertions-core:5.0.2")
+            }
+        }
+
+        val jvmTest by getting {
+            dependencies {
+                api("com.github.stefanbirkner:system-rules:1.19.0")
             }
         }
 
         val nativeMain by creating { dependsOn(commonMain) }
-        listOf("macosX64", "linuxX64", "mingwX64").forEach { target ->
+        val macosMain by creating { dependsOn(nativeMain) }
+        listOf("macosX64", "macosArm64").forEach { target ->
+            getByName(target + "Main").dependsOn(macosMain)
+        }
+        listOf("macos", "linuxX64", "mingwX64").forEach { target ->
             getByName(target + "Main").dependsOn(nativeMain)
         }
 
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 b00aeba0..1133cd57 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
@@ -25,10 +25,14 @@ internal expect fun stdoutInteractive(): Boolean
 
 internal expect fun stdinInteractive(): Boolean
 
+internal expect fun stderrInteractive(): Boolean
+
 internal expect fun codepointSequence(string: String): Sequence<Int>
 
 internal expect fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor
 
+internal expect fun printStderr(message: String, newline: Boolean)
+
 @OptIn(ExperimentalTerminalApi::class)
 internal expect fun sendInterceptedPrintRequest(
     request: PrintRequest,
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/MarkdownRenderer.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/MarkdownRenderer.kt
index 80286af4..7e32cbb7 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/MarkdownRenderer.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/MarkdownRenderer.kt
@@ -6,8 +6,8 @@ import com.github.ajalt.mordant.internal.EMPTY_LINES
 import com.github.ajalt.mordant.internal.generateHyperlinkId
 import com.github.ajalt.mordant.internal.parseText
 import com.github.ajalt.mordant.rendering.*
-import com.github.ajalt.mordant.rendering.BorderStyle.Companion.ASCII_DOUBLE_SECTION_SEPARATOR
-import com.github.ajalt.mordant.rendering.BorderStyle.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR
+import com.github.ajalt.mordant.rendering.BorderType.Companion.ASCII_DOUBLE_SECTION_SEPARATOR
+import com.github.ajalt.mordant.rendering.BorderType.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR
 import com.github.ajalt.mordant.table.SectionBuilder
 import com.github.ajalt.mordant.table.table
 import com.github.ajalt.mordant.terminal.Terminal
@@ -158,7 +158,7 @@ internal class MarkdownRenderer(
             }
 
             GFMElementTypes.TABLE -> table {
-                borderStyle = when {
+                borderType = when {
                     theme.flag("markdown.table.ascii") -> ASCII_DOUBLE_SECTION_SEPARATOR
                     else -> SQUARE_DOUBLE_SECTION_SEPARATOR
                 }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/BorderStyle.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/BorderType.kt
similarity index 91%
rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/BorderStyle.kt
rename to mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/BorderType.kt
index 62be2134..4c1a3e72 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/BorderStyle.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/BorderType.kt
@@ -3,7 +3,7 @@ package com.github.ajalt.mordant.rendering
 import com.github.ajalt.mordant.internal.DEFAULT_STYLE
 
 
-class BorderStyleSection(
+class BorderTypeSection(
     private val corners: String,
 ) {
     init {
@@ -35,12 +35,12 @@ class BorderStyleSection(
     }
 }
 
-class BorderStyle(
-    val head: BorderStyleSection,
-    val headBottom: BorderStyleSection,
-    val body: BorderStyleSection,
-    val bodyBottom: BorderStyleSection,
-    val foot: BorderStyleSection,
+class BorderType(
+    val head: BorderTypeSection,
+    val headBottom: BorderTypeSection,
+    val body: BorderTypeSection,
+    val bodyBottom: BorderTypeSection,
+    val foot: BorderTypeSection,
 ) {
     companion object {
         fun build(
@@ -49,12 +49,12 @@ class BorderStyle(
             body: String = head,
             bodyBottom: String = body,
             foot: String = body,
-        ): BorderStyle = BorderStyle(
-            head = BorderStyleSection(head),
-            headBottom = BorderStyleSection(headBottom),
-            body = BorderStyleSection(body),
-            bodyBottom = BorderStyleSection(bodyBottom),
-            foot = BorderStyleSection(foot),
+        ): BorderType = BorderType(
+            head = BorderTypeSection(head),
+            headBottom = BorderTypeSection(headBottom),
+            body = BorderTypeSection(body),
+            bodyBottom = BorderTypeSection(bodyBottom),
+            foot = BorderTypeSection(foot),
         )
 
         /**
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 67bdd555..87f83ba0 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
@@ -69,8 +69,8 @@ internal class TableWithCaption(
 
 internal class TableImpl(
     val rows: List<ImmutableRow>,
-    val borderStyle: BorderStyle,
-    val borderTextStyle: TextStyle,
+    val borderType: BorderType,
+    val borderStyle: TextStyle,
     val headerRowCount: Int,
     val footerRowCount: Int,
     val columnStyles: Map<Int, ColumnWidth>,
@@ -108,8 +108,8 @@ internal class TableImpl(
     override fun render(t: Terminal, width: Int): Lines {
         return TableRenderer(
             rows = rows,
+            borderType = borderType,
             borderStyle = borderStyle,
-            borderTextStyle = borderTextStyle,
             headerRowCount = headerRowCount,
             footerRowCount = footerRowCount,
             columnCount = columnCount,
@@ -205,8 +205,8 @@ internal class TableImpl(
 
 private class TableRenderer(
     val rows: List<ImmutableRow>,
-    val borderStyle: BorderStyle,
-    val borderTextStyle: TextStyle,
+    val borderType: BorderType,
+    val borderStyle: TextStyle,
     val headerRowCount: Int,
     val footerRowCount: Int,
     val columnCount: Int,
@@ -293,7 +293,7 @@ private class TableRenderer(
         if (colWidth == 0 || borderTop == null) return 1
 
         val char = if (borderTop || cellAt(x, y - 1)?.borderBottom == true) sectionOfRow(y).ew else " "
-        tableLines[tableLineY].add(Span.word(char.repeat(colWidth), borderTextStyle))
+        tableLines[tableLineY].add(Span.word(char.repeat(colWidth), borderStyle))
         return 1
     }
 
@@ -315,7 +315,7 @@ private class TableRenderer(
             if (borderLeft != null) {
                 val border = when {
                     borderLeft || cellAt(x - 1, y)?.borderRight == true -> {
-                        Span.word(sectionOfRow(y, allowBottom = false).ns, borderTextStyle)
+                        Span.word(sectionOfRow(y, allowBottom = false).ns, borderStyle)
                     }
                     else -> SINGLE_SPACE
                 }
@@ -373,17 +373,17 @@ private class TableRenderer(
             tr?.borderBottom == true || br?.borderTop == true,
             bl?.borderRight == true || br?.borderLeft == true,
             tl?.borderBottom == true || bl?.borderTop == true,
-            borderTextStyle
+            borderStyle
         )
     }
 
-    private fun sectionOfRow(y: Int, allowBottom: Boolean = true): BorderStyleSection {
+    private fun sectionOfRow(y: Int, allowBottom: Boolean = true): BorderTypeSection {
         return when {
-            y < headerRowCount -> borderStyle.head
-            allowBottom && headerRowCount > 0 && y == headerRowCount -> borderStyle.headBottom
-            allowBottom && footerRowCount > 0 && y == rowCount - footerRowCount -> borderStyle.bodyBottom
-            footerRowCount == 0 || y < rowCount - footerRowCount -> borderStyle.body
-            else -> borderStyle.foot
+            y < headerRowCount -> borderType.head
+            allowBottom && headerRowCount > 0 && y == headerRowCount -> borderType.headBottom
+            allowBottom && footerRowCount > 0 && y == rowCount - footerRowCount -> borderType.bodyBottom
+            footerRowCount == 0 || y < rowCount - footerRowCount -> borderType.body
+            else -> borderType.foot
         }
     }
 }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt
index 3c2d7c2a..8104b27a 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt
@@ -100,8 +100,8 @@ private fun Table.getContentRows(): List<List<String>> {
             width = Int.MAX_VALUE,
             height = Int.MAX_VALUE,
             hyperlinks = false,
-            stdoutInteractive = false,
-            stdinInteractive = false
+            outputInteractive = false,
+            inputInteractive = false,
         )
     )
 
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDsl.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDsl.kt
index 46049561..533b1d22 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDsl.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableDsl.kt
@@ -87,8 +87,8 @@ interface ColumnBuilder : CellStyleBuilder {
 
 @MordantDsl
 interface TableBuilder : CellStyleBuilder {
-    var borderStyle: BorderStyle
-    var borderTextStyle: TextStyle
+    var borderType: BorderType
+    var borderStyle: TextStyle
     var outerBorder: Boolean
 
     /** Add a [widget] as a caption oto the top of this table. */
@@ -245,7 +245,7 @@ fun grid(init: GridBuilder.() -> Unit): Widget {
     val tableBuilder = TableBuilderInstance().apply {
         borders = Borders.LEFT_RIGHT
         outerBorder = false
-        borderStyle = BorderStyle.BLANK
+        borderType = BorderType.BLANK
         padding = Padding.none()
         val gb = GridBuilderInstance(bodySection)
         gb.init()
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 a946ffcc..121435a5 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
@@ -23,8 +23,8 @@ internal class ColumnBuilderInstance : ColumnBuilder, CellStyleBuilder by CellSt
 
 @MordantDsl
 internal class TableBuilderInstance : TableBuilder, CellStyleBuilder by CellStyleBuilderMixin() {
-    override var borderStyle: BorderStyle = BorderStyle.SQUARE
-    override var borderTextStyle: TextStyle = DEFAULT_STYLE
+    override var borderType: BorderType = BorderType.SQUARE
+    override var borderStyle: TextStyle = DEFAULT_STYLE
     override var outerBorder: Boolean = true
 
     val columns = mutableMapOf<Int, ColumnBuilder>()
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 4fd13586..23048940 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
@@ -19,8 +19,8 @@ internal class TableLayout(private val table: TableBuilderInstance) {
 
         return TableImpl(
             rows = listOf(header, body, footer).flatten(),
+            borderType = table.borderType,
             borderStyle = table.borderStyle,
-            borderTextStyle = table.borderTextStyle,
             headerRowCount = header.size,
             footerRowCount = footer.size,
             columnStyles = table.columns.mapValues { it.value.width },
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
index 58927785..d00ba908 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
@@ -1,18 +1,29 @@
 package com.github.ajalt.mordant.terminal
 
+import com.github.ajalt.mordant.internal.printStderr
 import com.github.ajalt.mordant.rendering.AnsiLevel
 
 @ExperimentalTerminalApi
-internal class StdoutTerminalInterface(
-    ansiLevel: AnsiLevel?,
-    width: Int?,
-    height: Int?,
-    hyperlinks: Boolean?,
-    interactive: Boolean?,
+internal class StdoutTerminalInterface private constructor(
+    override val info: TerminalInfo,
+    private val errInfo: TerminalInfo,
+    private val useStdErr: Boolean,
 ) : TerminalInterface {
-    override val info = TerminalDetection.detectTerminal(ansiLevel, width, height, hyperlinks, interactive)
+    constructor(
+        ansiLevel: AnsiLevel?,
+        width: Int?,
+        height: Int?,
+        hyperlinks: Boolean?,
+        interactive: Boolean?,
+    ) : this(TerminalDetection.detectTerminal(false, ansiLevel, width, height, hyperlinks, interactive),
+        TerminalDetection.detectTerminal(true, ansiLevel, width, height, hyperlinks, interactive), false)
 
     override fun completePrintRequest(request: PrintRequest) {
+        if (useStdErr) {
+            printStderr(request.text, request.trailingLinebreak)
+            return
+        }
+
         if (request.trailingLinebreak) {
             if (request.text.isEmpty()) {
                 println()
@@ -23,4 +34,8 @@ internal class StdoutTerminalInterface(
             print(request.text)
         }
     }
+
+    override fun forStdErr(): TerminalInterface {
+        return StdoutTerminalInterface(info, errInfo, true)
+    }
 }
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 1ef42d3a..c64e4b90 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
@@ -65,7 +65,7 @@ class Terminal(
     val colors: TerminalColors = TerminalColors(info, theme)
 
     /**
-     * Functions for control the terminal's cursor.
+     * Functions for controlling the terminal's cursor.
      *
      * If the terminal is not interactive, all of the cursor functions are no-ops.
      */
@@ -242,6 +242,13 @@ class Terminal(
         return renderLinesAnsi(widget.render(this), info.ansiLevel, info.ansiHyperLinks)
     }
 
+    /**
+     * Create a copy of this terminal that prints to stderr if possible.
+     */
+    fun forStdErr(): Terminal {
+        return Terminal(theme, tabWidth, terminalInterface.forStdErr())
+    }
+
     internal fun addInterceptor(interceptor: TerminalInterceptor) {
         interceptors += interceptor
     }
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 9cf9a59f..1f2993eb 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
@@ -6,36 +6,39 @@ import com.github.ajalt.mordant.rendering.AnsiLevel.*
 
 internal object TerminalDetection {
     fun detectTerminal(
+        stderr: Boolean,
         ansiLevel: AnsiLevel?,
         width: Int?,
         height: Int?,
         hyperlinks: Boolean?,
         interactive: Boolean?,
     ): TerminalInfo {
-        val stdoutInteractive = interactive ?: stdoutInteractive()
-        val stdinInteractive = interactive ?: stdinInteractive()
-        val level = ansiLevel ?: ansiLevel(stdoutInteractive)
-        val ansiHyperLinks = hyperlinks ?: (stdoutInteractive && level != NONE && ansiHyperLinks())
+        val ij = isIntellijConsole() // intellij console is interactive, even through System.console == null
+        val inputInteractive = interactive ?: if (stderr) false else (ij || stdinInteractive())
+        val outputInteractive = interactive ?: (ij || (if (stderr) stderrInteractive() else stdoutInteractive()))
+        val level = ansiLevel ?: ansiLevel(outputInteractive)
+        val ansiHyperLinks = hyperlinks ?: (outputInteractive && level != NONE && ansiHyperLinks())
         val (w, h) = detectInitialSize()
         return TerminalInfo(
             width = width ?: w,
             height = height ?: h,
             ansiLevel = level,
             ansiHyperLinks = ansiHyperLinks,
-            stdoutInteractive = stdoutInteractive,
-            stdinInteractive = stdinInteractive
+            outputInteractive = outputInteractive,
+            inputInteractive = inputInteractive,
+            crClearsLine = ij
         )
     }
 
-    /** Returns pair of [width, height], or null if it can't be detected */
+    /** Returns a pair of `[width, height]`, or `null` if the size can't be detected */
     fun detectSize(timeoutMs: Long): Pair<Int, Int>? = getTerminalSize(timeoutMs)
 
     private fun detectInitialSize(): Pair<Int, Int> {
         val detected = when {
-            terminalSizeDetectionIsFast() -> getTerminalSize(100)
+            terminalSizeDetectionIsFast() -> getTerminalSize(timeoutMs = 100)
             else -> null
         }
-        return detected ?: (getEnv("COLUMNS")?.toInt() ?: 79) to (getEnv("LINES")?.toInt() ?: 24)
+        return detected ?: ((getEnv("COLUMNS")?.toIntOrNull() ?: 79) to (getEnv("LINES")?.toIntOrNull() ?: 24))
     }
 
     // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
@@ -48,15 +51,15 @@ internal object TerminalDetection {
     }
 
 
-    private fun ansiLevel(stdoutInteractive: Boolean): AnsiLevel {
+    private fun ansiLevel(interactive: Boolean): AnsiLevel {
         forcedColor()?.let { return it }
 
         // Terminals embedded in some IDEs support color even though stdout isn't interactive. Check
         // those terminals before checking stdout.
         if (isIntellijConsole() || isVsCodeTerminal()) return TRUECOLOR
 
-        // If stdout isn't interactive, never output colors, since we might be redirected to a file etc.
-        if (!stdoutInteractive) return NONE
+        // If output isn't interactive, never output colors, since we might be redirected to a file etc.
+        if (!interactive) return NONE
 
         // Otherwise check the large variety of environment variables set by various terminal
         // emulators to detect color support
@@ -80,7 +83,7 @@ internal object TerminalDetection {
 
         val (term, level) = getTerm()?.split("-")
             ?.let { it.firstOrNull() to it.lastOrNull() }
-            ?: null to null
+            ?: (null to null)
 
         when (level) {
             "256", "256color", "256colors" -> return ANSI256
@@ -93,10 +96,10 @@ internal object TerminalDetection {
             "cygwin" -> when {
                 // New versions of windows 10 cmd.exe supports truecolor, and most other terminal emulators
                 // like ConEmu and mintty support truecolor, although they might downsample it.
-                getJavaProperty("os.name") == "Windows 10" -> TRUECOLOR
+                getJavaProperty("os.name") in listOf("Windows 10", "Windows 11") -> TRUECOLOR
                 else -> ANSI256
             }
-            "xterm", "vt100", "vt220", "screen", "color", "linux", "ansi", "rxvt", "konsole" -> ANSI16
+            "xterm", "vt100", "vt220", "screen", "tmux", "color", "linux", "ansi", "rxvt", "konsole" -> ANSI16
             "dumb" -> NONE
             else -> NONE
         }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt
index 30a78028..018e8030 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt
@@ -3,13 +3,29 @@ package com.github.ajalt.mordant.terminal
 import com.github.ajalt.mordant.rendering.AnsiLevel
 
 
+/**
+ * Information about the current terminal
+ *
+ * [width] and [height] don't automatically change if the terminal is resized. Call [updateTerminalSize] to get the
+ * latest values.
+ *
+ * @property width The terminal width, in cells
+ * @property height The terminal height, in cells
+ * @property ansiLevel The level of ANSI codes to use when printing to the terminal
+ * @property ansiHyperLinks If true, ANSI hyperlink codes can be used
+ * @property outputInteractive If false the output stream is not an interactive terminal, such as when it's redirected to a file
+ * @property inputInteractive If false the output stream is not an interactive terminal, such as when it's redirected from a file
+ * @property crClearsLine If true, `\r` will clear the entire line it's printed on in the current terminal, if false,
+ *   `\r` will only move the cursor
+ */
 class TerminalInfo(
     width: Int,
     height: Int,
     var ansiLevel: AnsiLevel,
     var ansiHyperLinks: Boolean,
-    val stdoutInteractive: Boolean,
-    val stdinInteractive: Boolean,
+    val outputInteractive: Boolean,
+    val inputInteractive: Boolean,
+    val crClearsLine: Boolean,
 ) {
     var width: Int = width
         private set
@@ -17,13 +33,14 @@ class TerminalInfo(
         private set
 
 
-    /** Return true if both stdin and stdout are interactive */
-    val interactive: Boolean get() = stdinInteractive && stdinInteractive
+    /** Return true if both input and output are interactive */
+    val interactive: Boolean get() = inputInteractive && inputInteractive
 
     /**
      * Query the terminal for its current size, updating [width] and [height] if successful.
      *
-     * This call will create subprocess and block for up to [timeoutMs] waiting for it to complete.
+     * On JVM, this call will create a subprocess and block for up to [timeoutMs] waiting for it to complete.
+     * On other platforms, this call doesn't require a subprocess, and [timeoutMs] is ignored.
      *
      * @return `true` if the size was updated, of `false` if it was not
      */
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt
index 4daedc95..8ac10ffe 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt
@@ -5,6 +5,14 @@ package com.github.ajalt.mordant.terminal
 interface TerminalInterface {
     val info: TerminalInfo
     fun completePrintRequest(request: PrintRequest)
+
+    /**
+     * Return an interface that should print to stderr if possible.
+     *
+     * If the interface doesn't support stdErr (for example, if it writes to an html field in the browser rather than a
+     * desktop terminal), the same instance may be returned.
+     */
+    fun forStdErr(): TerminalInterface
 }
 
 @ExperimentalTerminalApi
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt
index 82213b25..77422414 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt
@@ -8,16 +8,18 @@ class VirtualTerminalInterface(
     width: Int = 79,
     height: Int = 24,
     hyperlinks: Boolean = ansiLevel != AnsiLevel.NONE,
-    stdoutInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
-    stdinInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+    outputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+    inputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+    crClearsLine: Boolean = false,
 ) : TerminalInterface {
     override val info = TerminalInfo(
         width,
         height,
         ansiLevel,
         hyperlinks,
-        stdoutInteractive = stdoutInteractive,
-        stdinInteractive = stdinInteractive
+        outputInteractive = outputInteractive,
+        inputInteractive = inputInteractive,
+        crClearsLine = crClearsLine,
     )
 
     private val sb = StringBuilder()
@@ -34,5 +36,7 @@ class VirtualTerminalInterface(
             sb.append("\n")
         }
     }
+
+    override fun forStdErr(): TerminalInterface = this
 }
 
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/HorizontalRule.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/HorizontalRule.kt
index 6bbd6f6c..82f8569c 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/HorizontalRule.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/HorizontalRule.kt
@@ -12,6 +12,7 @@ import com.github.ajalt.mordant.terminal.Terminal
  * @property ruleStyle The style of the rule. By default, uses the theme value `hr.rule`.
  * @property titleAlign If [title] is defined, how to align it within the rule
  * @property titlePadding The amount of space to draw between the [title] and the rule. By default, uses the theme value `hr.title.padding`.
+ * @property titleOverflowTop If true, [title] widgets that are more than one line will have their extra lines above the rule. If false, the extra lines will be below the rule.
  */
 class HorizontalRule internal constructor(
     private val title: Widget,
@@ -19,6 +20,7 @@ class HorizontalRule internal constructor(
     private val ruleStyle: ThemeStyle,
     private val titleAlign: TextAlign,
     private val titlePadding: ThemeDimension,
+    private val titleOverflowTop: Boolean,
 ) : Widget {
     constructor(
         title: Widget = EmptyWidget,
@@ -26,12 +28,14 @@ class HorizontalRule internal constructor(
         ruleStyle: TextStyle? = null,
         titleAlign: TextAlign = TextAlign.CENTER,
         titlePadding: Int? = null,
+        titleOverflowTop: Boolean = true,
     ) : this(
         title = title,
         ruleCharacter = ThemeString.of("hr.rule", ruleCharacter, " "),
         ruleStyle = ThemeStyle.of("hr.rule", ruleStyle),
         titleAlign = titleAlign,
         titlePadding = ThemeDimension.of("hr.title.padding", titlePadding),
+        titleOverflowTop = titleOverflowTop,
     )
 
     constructor(
@@ -40,12 +44,14 @@ class HorizontalRule internal constructor(
         ruleStyle: TextStyle? = null,
         titleAlign: TextAlign = TextAlign.CENTER,
         titlePadding: Int? = null,
+        titleOverflowTop: Boolean = true,
     ) : this(
         title = if (title.isEmpty()) EmptyWidget else Text(title),
         ruleCharacter = ThemeString.of("hr.rule", ruleCharacter, " "),
         ruleStyle = ThemeStyle.of("hr.rule", ruleStyle),
         titleAlign = titleAlign,
         titlePadding = ThemeDimension.of("hr.title.padding", titlePadding),
+        titleOverflowTop = titleOverflowTop,
     )
 
     override fun measure(t: Terminal, width: Int): WidthRange {
@@ -60,8 +66,8 @@ class HorizontalRule internal constructor(
         val lines = if (renderedTitle.isEmpty()) {
             listOf(rule(t, width))
         } else {
-            val lastLine = renderedTitle.lines.last()
-            val ruleWidth = width - lastLine.sumOf { it.cellWidth } - totalPadding
+            val titleRuleLine = if (titleOverflowTop) renderedTitle.lines.last() else renderedTitle.lines.first()
+            val ruleWidth = width - titleRuleLine.sumOf { it.cellWidth } - totalPadding
             val leftRuleWidth = when (titleAlign) {
                 TextAlign.LEFT -> 1
                 TextAlign.RIGHT -> ruleWidth - 1
@@ -73,13 +79,20 @@ class HorizontalRule internal constructor(
             val leftRule = rule(t, leftRuleWidth)
             val rightRule = rule(t, ruleWidth - leftRuleWidth)
             val space = if (padding > 0) Span.space(padding, ruleStyle[t]) else null
-            val rule = flatLine(leftRule, space, lastLine, space, rightRule)
-            if (renderedTitle.lines.size > 1) {
-                val firstLines = Lines(renderedTitle.lines.dropLast(1))
-                    .setSize(width, textAlign = TextAlign.CENTER)
-                firstLines.lines + listOf(rule)
-            } else {
-                listOf(rule)
+            val rule = flatLine(leftRule, space, titleRuleLine, space, rightRule)
+            buildList {
+                fun addT(l: List<Line>) {
+                    addAll(Lines(l).setSize(width, textAlign = TextAlign.CENTER).lines)
+                }
+                if (renderedTitle.lines.size == 1) {
+                    add(rule)
+                } else if (titleOverflowTop) {
+                    addT(renderedTitle.lines.dropLast(1))
+                    add(rule)
+                } else {
+                    add(rule)
+                    addT(renderedTitle.lines.drop(1))
+                }
             }
         }
         return Lines(lines)
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 460931e8..9a858fe7 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
@@ -12,58 +12,113 @@ import com.github.ajalt.mordant.rendering.Whitespace.NOWRAP
 import com.github.ajalt.mordant.terminal.Terminal
 
 
+/**
+ * A box drawn around another widget, with optional top and bottom titles.
+ *
+ * ## Example
+ *
+ * ```
+ * terminal.print(Panel(
+ *   "my panel content",
+ *   title = "title",
+ *   bottomTitle = "subtitle",
+ *   titleAlign = TextAlign.LEFT,
+ *   bottomTitleAlign = TextAlign.RIGHT
+ * ))
+ * ```
+ *
+ * ```
+ * ╭─ title ────────╮
+ * │my panel content│
+ * ╰───── subtitle ─╯
+ * ```
+ */
 class Panel private constructor(
     content: Widget,
     private val title: Widget?,
+    private val bottomTitle: Widget?,
     private val expand: Boolean,
     padding: Padding,
-    private val borderStyle: BorderStyle?,
+    private val borderType: BorderType?,
     private val titleAlign: TextAlign,
-    private val borderTextStyle: ThemeStyle,
+    private val bottomTitleAlign: TextAlign,
+    private val borderStyle: ThemeStyle,
     private val titlePadding: ThemeDimension,
 ) : Widget {
+    /**
+     * @param content The content to draw in the center of the panel.
+     * @param title The content to draw in the top border
+     * @param bottomTitle The content to draw in the bottom border
+     * @param expand If true, the panel will expand horizontally  to fill the screen. If false, it will be just wide enough to fit the content.
+     * @param padding Extra padding to add between the content and the panel border. (default: no extra padding)
+     * @param borderType The type of border to draw around the content, or `null` for no border
+     * @param titleAlign The horizontal alignment for the top title (default: center)
+     * @param bottomTitleAlign The horizontal alignment for the bottom title (default: copy [titleAlign])
+     * @param borderStyle The [TextStyle] for the border (default: Theme style `panel.border`)
+     * @param titlePadding Amount of horizontal padding to add between the titles and the border characters (default: Theme value `panel.title.padding`)
+     */
     constructor(
         content: Widget,
         title: Widget? = null,
+        bottomTitle: Widget? = null,
         expand: Boolean = false,
         padding: Padding = DEFAULT_PADDING,
-        borderStyle: BorderStyle? = BorderStyle.ROUNDED,
+        borderType: BorderType? = BorderType.ROUNDED,
         titleAlign: TextAlign = CENTER,
-        borderTextStyle: TextStyle? = null,
+        bottomTitleAlign: TextAlign = titleAlign,
+        borderStyle: TextStyle? = null,
         titlePadding: Int? = null,
     ) : this(
         content = content,
         title = title,
+        bottomTitle = bottomTitle,
         expand = expand,
         padding = padding,
-        borderStyle = borderStyle,
+        borderType = borderType,
         titleAlign = titleAlign,
-        borderTextStyle = ThemeStyle.of("panel.border", borderTextStyle),
+        bottomTitleAlign = bottomTitleAlign,
+        borderStyle = ThemeStyle.of("panel.border", borderStyle),
         titlePadding = ThemeDimension.of("panel.title.padding", titlePadding),
     )
 
+    /**
+     * @param content The content to draw in the center of the panel.
+     * @param title The content to draw in the top border
+     * @param bottomTitle The content to draw in the bottom border
+     * @param expand If true, the panel will expand horizontally  to fill the screen. If false, it will be just wide enough to fit the content.
+     * @param padding Extra padding to add between the content and the panel border. (default: no extra padding)
+     * @param borderType The type of border to draw around the content, or `null` for no border
+     * @param titleAlign The horizontal alignment for the top title (default: center)
+     * @param bottomTitleAlign The horizontal alignment for the bottom title (default: copy [titleAlign])
+     * @param borderStyle The [TextStyle] for the border (default: Theme style `panel.border`)
+     * @param titlePadding Amount of horizontal padding to add between the titles and the border characters (default: Theme value `panel.title.padding`)
+     */
     constructor(
         content: String,
         title: String? = null,
+        bottomTitle: String? = null,
         expand: Boolean = false,
         padding: Padding = DEFAULT_PADDING,
-        borderStyle: BorderStyle? = BorderStyle.ROUNDED,
+        borderType: BorderType? = BorderType.ROUNDED,
         titleAlign: TextAlign = CENTER,
-        borderTextStyle: TextStyle? = null,
+        bottomTitleAlign: TextAlign = titleAlign,
+        borderStyle: TextStyle? = null,
         titlePadding: Int? = null,
     ) : this(
         content = Text(content),
         title = title?.let { Text(it, overflowWrap = ELLIPSES, whitespace = NOWRAP) },
+        bottomTitle = bottomTitle?.let { Text(it, overflowWrap = ELLIPSES, whitespace = NOWRAP) },
         expand = expand,
         padding = padding,
-        borderStyle = borderStyle,
+        borderType = borderType,
         titleAlign = titleAlign,
-        borderTextStyle = ThemeStyle.of("panel.border", borderTextStyle),
+        bottomTitleAlign = bottomTitleAlign,
+        borderStyle = ThemeStyle.of("panel.border", borderStyle),
         titlePadding = ThemeDimension.of("panel.title.padding", titlePadding),
     )
 
     private val content: Widget = content.withPadding(padding)
-    private val borderWidth get() = if (borderStyle == null) 0 else 2
+    private val borderWidth get() = if (borderType == null) 0 else 2
 
     private fun maxContentWidth(width: Int) = (width - borderWidth).coerceAtLeast(0)
     private fun maxTitleWidth(titlePadding: Int, width: Int): Int {
@@ -84,7 +139,7 @@ class Panel private constructor(
     override fun render(t: Terminal, width: Int): Lines {
         val measurement = measure(t, width)
         val maxContentWidth = maxContentWidth(width)
-        val borderTextStyle = borderTextStyle[t]
+        val borderStyle = borderStyle[t]
 
         val contentWidth = when {
             expand -> maxContentWidth
@@ -92,37 +147,50 @@ class Panel private constructor(
         }
 
         val renderedContent = content.render(t, maxContentWidth).setSize(contentWidth, textAlign = LEFT)
-        val renderedTitle = HorizontalRule(
+        val renderedTop = HorizontalRule(
             title ?: EmptyWidget,
-            ThemeString.Explicit(borderStyle?.body?.ew ?: " "),
-            this.borderTextStyle,
+            ThemeString.Explicit(borderType?.body?.ew ?: " "),
+            this.borderStyle,
             titleAlign,
-            titlePadding
+            titlePadding,
+            titleOverflowTop = true
         ).render(t, contentWidth)
 
-        if (borderStyle == null) {
-            return when (title) {
-                null -> renderedContent
-                else -> Lines(renderedTitle.lines + renderedContent.lines)
-            }
+        val renderedBottom = HorizontalRule(
+            bottomTitle ?: EmptyWidget,
+            ThemeString.Explicit(borderType?.body?.ew ?: " "),
+            this.borderStyle,
+            bottomTitleAlign,
+            titlePadding,
+            titleOverflowTop = false
+        ).render(t, contentWidth)
+
+        if (borderType == null) {
+            return Lines(buildList {
+                if (title != null) addAll(renderedTop.lines)
+                addAll(renderedContent.lines)
+                if (bottomTitle != null) addAll(renderedBottom.lines)
+            })
         }
 
-        val lines = ArrayList<Line>(renderedContent.height + renderedTitle.height + borderWidth)
-        val b = borderStyle.body
-        val horizontalBorder = Span.word(b.ew.repeat(contentWidth), borderTextStyle)
+        val lines = ArrayList<Line>(renderedContent.height + renderedTop.height + renderedBottom.height)
+        val b = borderType.body
+        val vertical = listOf(Span.word(b.ns, borderStyle))
 
-        renderedTitle.lines.mapIndexedTo(lines) { i, it ->
-            if (i < renderedTitle.lines.lastIndex) it
-            else flatLine(Span.word(b.es, borderTextStyle), it, Span.word(b.sw, borderTextStyle))
+        renderedTop.lines.mapIndexedTo(lines) { i, it ->
+            if (i < renderedTop.lines.lastIndex) flatLine(Span.space(1), it, Span.space(1))
+            else flatLine(Span.word(b.es, borderStyle), it, Span.word(b.sw, borderStyle))
         }
 
-        val vertical = listOf(Span.word(b.ns, borderTextStyle))
-
         renderedContent.lines.mapTo(lines) { line ->
             flatLine(vertical, line, vertical)
         }
 
-        lines += Line(listOf(Span.word(b.ne, borderTextStyle), horizontalBorder, Span.word(b.nw, borderTextStyle)))
+        renderedBottom.lines.mapIndexedTo(lines) { i, it ->
+            if (i > 0) flatLine(Span.space(1), it, Span.space(1))
+            else flatLine(Span.word(b.ne, borderStyle), it, Span.word(b.nw, borderStyle))
+        }
+
         return Lines(lines)
     }
 }
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 cda38bcd..664f48eb 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
@@ -3,6 +3,7 @@ package com.github.ajalt.mordant.internal
 import com.github.ajalt.mordant.terminal.*
 
 private external val process: dynamic
+private external val console: dynamic
 private external val Symbol: dynamic
 
 internal actual class AtomicInt actual constructor(initial: Int) {
@@ -26,7 +27,9 @@ private interface JsMppImpls {
     fun isWindowsMpp(): Boolean
     fun stdoutInteractive(): Boolean
     fun stdinInteractive(): Boolean
+    fun stderrInteractive(): Boolean
     fun getTerminalSize(): Pair<Int, Int>?
+    fun printStderr(message: String, newline: Boolean)
 }
 
 private object BrowserMppImpls : JsMppImpls {
@@ -34,7 +37,12 @@ private object BrowserMppImpls : JsMppImpls {
     override fun isWindowsMpp(): Boolean = false
     override fun stdoutInteractive(): Boolean = false
     override fun stdinInteractive(): Boolean = false
+    override fun stderrInteractive(): Boolean = false
     override fun getTerminalSize(): Pair<Int, Int>? = null
+    override fun printStderr(message: String, newline: Boolean) {
+        // No way to avoid the newline on browsers
+        console.error(message)
+    }
 }
 
 private object NodeMppImpls : JsMppImpls {
@@ -42,6 +50,7 @@ private object NodeMppImpls : JsMppImpls {
     override fun isWindowsMpp(): Boolean = process.platform == "win32"
     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<Int, Int>? {
         // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY
         // is false
@@ -49,6 +58,11 @@ private object NodeMppImpls : JsMppImpls {
         val s = process.stdout.getWindowSize()
         return s[0] as Int to s[1] as Int
     }
+
+    override fun printStderr(message: String, newline: Boolean) {
+        val s = if (newline) message + "\n" else message
+        process.stderr.write(s)
+    }
 }
 
 private val impls: JsMppImpls = if (isNode) {
@@ -66,6 +80,8 @@ internal actual fun isWindows(): Boolean = impls.isWindowsMpp()
 internal actual fun getEnv(key: String): String? = impls.readEnvvar(key)
 internal actual fun stdoutInteractive(): Boolean = impls.stdoutInteractive()
 internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive()
+internal actual fun stderrInteractive(): Boolean = impls.stderrInteractive()
+internal actual fun printStderr(message: String, newline: Boolean) = impls.printStderr(message, newline)
 
 internal actual fun codepointSequence(string: String): Sequence<Int> {
     val it = string.asDynamic()[Symbol.iterator]()
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
index 8851aaf0..2880cd2c 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
@@ -1,20 +1,22 @@
 package com.github.ajalt.mordant.animation
 
 import com.github.ajalt.mordant.rendering.*
-import com.github.ajalt.mordant.terminal.ExperimentalTerminalApi
-import com.github.ajalt.mordant.terminal.PrintRequest
-import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.terminal.TerminalInterceptor
+import com.github.ajalt.mordant.terminal.*
 import com.github.ajalt.mordant.widgets.EmptyWidget
 import com.github.ajalt.mordant.widgets.RawWidget
 import com.github.ajalt.mordant.widgets.Text
 
 /**
- * A base class for objects that can turn arbitrary objects into rendered widgets.
+ * An Animation renders a widget to the screen each time [update] is called, clearing the render from the previous call.
  *
- * [Animation] by itself doesn't handle actually triggering frames - if you use this that's up to you. Instead, it handles clearing the
- * areas of the screen needed as the widgets change, and ensuring that anything rendered via [Terminal.println] appears above the animated
- * widgets. Override [renderData] to provide a [Widget] for display.
+ * If you [print][Terminal.print] anything in between calls to [update], the rendered widget will be cleared, before the
+ * print, then rendered again below it.
+ *
+ * Note that [Animation] doesn't render the widget automatically: you need to call [update] yourself when your data
+ * changes. If your terminal is not [interactive][TerminalInfo.interactive], the animation will not render anything.
+ *
+ * You can create instances of Animations with [animation], [textAnimation], and [progressAnimation], or by creating a
+ * subclass.
  */
 @OptIn(ExperimentalTerminalApi::class)
 abstract class Animation<T>(private val terminal: Terminal) {
@@ -28,8 +30,9 @@ abstract class Animation<T>(private val terminal: Terminal) {
     private val interceptor: TerminalInterceptor = TerminalInterceptor { req ->
         text?.let { t ->
             PrintRequest(text = buildString {
-                if (req.text.isNotEmpty())
+                if (req.text.isNotEmpty()) {
                     needsClear = true
+                }
 
                 if (!firstDraw) {
                     getClear()?.let { append(it) }
@@ -39,12 +42,12 @@ abstract class Animation<T>(private val terminal: Terminal) {
                     appendLine(req.text)
                 }
                 append(t)
-            }, trailingLinebreak = true)
+            }, trailingLinebreak = !terminal.info.crClearsLine)
         } ?: req
     }
 
     init {
-        if (terminal.info.stdoutInteractive) {
+        if (terminal.info.outputInteractive) {
             terminal.addInterceptor(interceptor)
         }
     }
@@ -83,7 +86,7 @@ abstract class Animation<T>(private val terminal: Terminal) {
 }
 
 /**
- * Returns an anonymous subclass of [Animation] that uses the [draw] function to render objects of type [T].
+ * Create an [Animation] that uses the [draw] function to render objects of type [T].
  *
  * @see Animation
  */
@@ -94,7 +97,7 @@ inline fun <T> Terminal.animation(crossinline draw: (T) -> Widget): Animation<T>
 }
 
 /**
- * Returns an anonymous subclass of [Animation] that wraps the result of the [draw] function into a [Text] and renders that.
+ * Create an [Animation] that wraps the result of the [draw] function into a [Text] widget and renders it.
  */
 inline fun <T> Terminal.textAnimation(
     whitespace: Whitespace = Whitespace.PRE,
@@ -105,7 +108,8 @@ inline fun <T> Terminal.textAnimation(
     crossinline draw: (T) -> String,
 ): Animation<T> {
     return object : Animation<T>(this) {
-        override fun renderData(data: T): Widget =
-            Text(draw(data), whitespace, align, overflowWrap, width, tabWidth)
+        override fun renderData(data: T): Widget {
+            return Text(draw(data), whitespace, align, overflowWrap, width, tabWidth)
+        }
     }
 }
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 4f32cc15..b43266f7 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
@@ -113,6 +113,11 @@ class ProgressAnimation internal constructor(
         }
     }
 
+    @Synchronized
+    fun update(completed: Int) {
+        update(completed.toLong())
+    }
+
     @Synchronized
     fun update() {
         update(history.completed)
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 f2d2cfa9..7a446a41 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
@@ -79,15 +79,25 @@ internal actual fun runningInIdeaJavaAgent() = try {
     false
 }
 
-// Unfortunately, the JVM doesn't let us check stdin and stdout separately.
+// Unfortunately, the JVM doesn't let us check stdin, stdout or stderr separately.
 internal actual fun stdoutInteractive(): Boolean = System.console() != null
 
 internal actual fun stdinInteractive(): Boolean = System.console() != null
 
+internal actual fun stderrInteractive(): Boolean = System.console() != null
+
 internal actual fun codepointSequence(string: String): Sequence<Int> {
     return string.codePoints().iterator().asSequence()
 }
 
+internal actual fun printStderr(message: String, newline: Boolean) {
+    if (newline) {
+        System.err.println(message)
+    } else {
+        System.err.print(message)
+    }
+}
+
 internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor = JvmTerminalCursor(terminal)
 
 private class JvmTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) {
diff --git a/mordant/src/macosX64Main/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
similarity index 100%
rename from mordant/src/macosX64Main/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
rename to mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
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 8dde3ef5..d1c784e9 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
@@ -3,12 +3,6 @@ package com.github.ajalt.mordant.internal
 import com.github.ajalt.mordant.terminal.*
 import kotlinx.cinterop.*
 import platform.posix.*
-import platform.posix.STDIN_FILENO
-import platform.posix.STDOUT_FILENO
-import platform.posix.atexit
-import platform.posix.getenv
-import platform.posix.isatty
-import kotlin.native.concurrent.AtomicInt
 import kotlin.native.concurrent.AtomicReference
 
 
@@ -39,6 +33,8 @@ internal actual fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0
 
 internal actual fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) != 0
 
+internal actual fun stderrInteractive(): Boolean = isatty(STDERR_FILENO) != 0
+
 internal actual fun codepointSequence(string: String): Sequence<Int> = sequence {
     var i = 0
     while (i < string.length) {
@@ -52,6 +48,12 @@ internal actual fun codepointSequence(string: String): Sequence<Int> = sequence
     }
 }
 
+internal actual fun printStderr(message: String, newline: Boolean) {
+    val s = if (newline) message + "\n"  else message
+    fprintf(stderr, s)
+    fflush(stderr)
+}
+
 internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor = NativeTerminalCursor(terminal)
 
 
diff --git a/samples/table/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/table/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
index b91736a1..fe42fab0 100644
--- a/samples/table/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
+++ b/samples/table/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
@@ -1,11 +1,10 @@
 package com.github.ajalt.mordant.samples
 
-import com.github.ajalt.mordant.rendering.BorderStyle.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR
+import com.github.ajalt.mordant.rendering.BorderType.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR
 import com.github.ajalt.mordant.rendering.TextAlign.LEFT
 import com.github.ajalt.mordant.rendering.TextAlign.RIGHT
 import com.github.ajalt.mordant.rendering.TextColors.*
 import com.github.ajalt.mordant.rendering.TextStyle
-import com.github.ajalt.mordant.rendering.TextStyles
 import com.github.ajalt.mordant.rendering.TextStyles.dim
 import com.github.ajalt.mordant.table.Borders.ALL
 import com.github.ajalt.mordant.table.Borders.TOM_BOTTOM
@@ -18,7 +17,7 @@ fun main() {
 
     val table = table {
         outerBorder = false
-        borderStyle = SQUARE_DOUBLE_SECTION_SEPARATOR
+        borderType = SQUARE_DOUBLE_SECTION_SEPARATOR
         align = RIGHT
         column(0) {
             align = LEFT


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0a36e286..a831176c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -24,7 +24,7 @@ jobs:
       - uses: actions/setup-java@v1
         with:
           java-version: 11
-      - uses: eskatos/gradle-command-action@v2
+      - uses: gradle/gradle-build-action@v2
         with:
           arguments: :mordant:jvmTest :mordant:jsTest ${{matrix.NATIVE_TEST_TASK}} --stacktrace
       - name: Bundle the build report
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableBorderStyleTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableBorderStyleTest.kt
index 6cdebedf..06de14e2 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableBorderStyleTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableBorderStyleTest.kt
@@ -1,16 +1,14 @@
 package com.github.ajalt.mordant.table
 
-import com.github.ajalt.mordant.rendering.BorderStyle
+import com.github.ajalt.mordant.rendering.BorderType
 import com.github.ajalt.mordant.table.Borders.*
-import com.github.ajalt.mordant.table.SectionBuilder
-import com.github.ajalt.mordant.table.table
 import com.github.ajalt.mordant.test.RenderingTest
 import com.github.ajalt.mordant.widgets.Padding
 import kotlin.test.Test
 
 class TableBorderStyleTest : RenderingTest() {
     @Test
-    fun square() = doTest(BorderStyle.SQUARE, """
+    fun square() = doTest(BorderType.SQUARE, """
         ┌───┬───┐                   ┌───┬───┐   ╷   ╷   ╷   ╷
         │1  │2  │3   4   5   6   7  │8  │9  │10 │11 │12 │13 │
         ├───┼───┤   ╶───────╴       └───┴───┘   ╵   ╵   ╵   ╵
@@ -35,7 +33,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun rounded() = doTest(BorderStyle.ROUNDED, """
+    fun rounded() = doTest(BorderType.ROUNDED, """
         ╭───┬───╮                   ╭───┬───╮   ╷   ╷   ╷   ╷
         │1  │2  │3   4   5   6   7  │8  │9  │10 │11 │12 │13 │
         ├───┼───┤   ╶───────╴       ╰───┴───╯   ╵   ╵   ╵   ╵
@@ -60,7 +58,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun heavy() = doTest(BorderStyle.HEAVY, """
+    fun heavy() = doTest(BorderType.HEAVY, """
         ┏━━━┳━━━┓                   ┏━━━┳━━━┓   ╻   ╻   ╻   ╻
         ┃1  ┃2  ┃3   4   5   6   7  ┃8  ┃9  ┃10 ┃11 ┃12 ┃13 ┃
         ┣━━━╋━━━┫   ╺━━━━━━━╸       ┗━━━┻━━━┛   ╹   ╹   ╹   ╹
@@ -85,7 +83,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun double() = doTest(BorderStyle.DOUBLE, """
+    fun double() = doTest(BorderType.DOUBLE, """
         ╔═══╦═══╗                   ╔═══╦═══╗                
         ║1  ║2  ║3   4   5   6   7  ║8  ║9  ║10 ║11 ║12 ║13 ║
         ╠═══╬═══╣    ═══════        ╚═══╩═══╝                
@@ -110,7 +108,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun heavy_head_foot() = doTest(BorderStyle.HEAVY_HEAD_FOOT, """
+    fun heavy_head_foot() = doTest(BorderType.HEAVY_HEAD_FOOT, """
         ┏━━━┳━━━┓                   ┏━━━┳━━━┓   ╻   ╻   ╻   ╻
         ┃1  ┃2  ┃3   4   5   6   7  ┃8  ┃9  ┃10 ┃11 ┃12 ┃13 ┃
         ┣━━━╋━━━┫   ╺━━━━━━━╸       ┗━━━┻━━━┛   ╹   ╹   ╹   ╹
@@ -135,7 +133,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun square_double_head_separator() = doTest(BorderStyle.SQUARE_DOUBLE_SECTION_SEPARATOR, """
+    fun square_double_head_separator() = doTest(BorderType.SQUARE_DOUBLE_SECTION_SEPARATOR, """
         ┌───┬───┐                   ┌───┬───┐   ╷   ╷   ╷   ╷
         │1  │2  │3   4   5   6   7  │8  │9  │10 │11 │12 │13 │
         ├───┼───┤   ╶───────╴       └───┴───┘   ╵   ╵   ╵   ╵
@@ -160,7 +158,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun ascii() = doTest(BorderStyle.ASCII, """
+    fun ascii() = doTest(BorderType.ASCII, """
         +---+---+                   +---+---+                
         |1  |2  |3   4   5   6   7  |8  |9  |10 |11 |12 |13 |
         +---+---+    -------        +---+---+                
@@ -185,7 +183,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun ascii_double_section_separator() = doTest(BorderStyle.ASCII_DOUBLE_SECTION_SEPARATOR, """
+    fun ascii_double_section_separator() = doTest(BorderType.ASCII_DOUBLE_SECTION_SEPARATOR, """
         +---+---+                   +---+---+                
         |1  |2  |3   4   5   6   7  |8  |9  |10 |11 |12 |13 |
         +---+---+    -------        +---+---+                
@@ -210,7 +208,7 @@ class TableBorderStyleTest : RenderingTest() {
         """)
 
     @Test
-    fun blank() = doTest(BorderStyle.BLANK, """
+    fun blank() = doTest(BorderType.BLANK, """
         |                                                     ⏎
         | 1   2   3   4   5   6   7   8   9   10  11  12  13  ⏎
         |                                                     ⏎
@@ -234,9 +232,9 @@ class TableBorderStyleTest : RenderingTest() {
         |                                                     ⏎
         """.trimMargin(), trimIndent = false)
 
-    private fun doTest(borderStyle: BorderStyle, expected: String, trimIndent: Boolean = true) {
+    private fun doTest(borderType: BorderType, expected: String, trimIndent: Boolean = true) {
         checkRender(table {
-            this.borderStyle = borderStyle
+            this.borderType = borderType
             padding = Padding.none()
 
             var i = 1
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableColumnWidthTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableColumnWidthTest.kt
index 2decd0b1..48a97df4 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableColumnWidthTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableColumnWidthTest.kt
@@ -1,16 +1,12 @@
 package com.github.ajalt.mordant.table
 
-import com.github.ajalt.mordant.rendering.BorderStyle
+import com.github.ajalt.mordant.rendering.BorderType
 import com.github.ajalt.mordant.rendering.OverflowWrap
 import com.github.ajalt.mordant.rendering.Whitespace
-import com.github.ajalt.mordant.table.Borders
 import com.github.ajalt.mordant.table.Borders.ALL
 import com.github.ajalt.mordant.table.Borders.LEFT_RIGHT
-import com.github.ajalt.mordant.table.ColumnWidth
-import com.github.ajalt.mordant.table.table
 import com.github.ajalt.mordant.test.RenderingTest
 import com.github.ajalt.mordant.widgets.Padding
-import com.github.ajalt.mordant.widgets.Text
 import kotlin.js.JsName
 import kotlin.test.Test
 
@@ -118,7 +114,7 @@ class TableColumnWidthTest : RenderingTest() {
 
     private fun doTest(tableWidth: Int, expected: String, borders: Borders = LEFT_RIGHT) {
         checkRender(table {
-            borderStyle = BorderStyle.ASCII
+            borderType = BorderType.ASCII
             padding = Padding.none()
             overflowWrap = OverflowWrap.TRUNCATE
             whitespace = Whitespace.NORMAL
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 4c83e751..d60c4beb 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
@@ -516,7 +516,7 @@ class TableTest : RenderingTest() {
 
     private fun doBodyTest(expected: String, builder: SectionBuilder.() -> Unit) {
         checkRender(table {
-            borderStyle = BorderStyle.HEAVY_HEAD_FOOT
+            borderType = BorderType.HEAVY_HEAD_FOOT
             body(builder)
         }, expected)
     }
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt
index b81ceba5..1ef19056 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt
@@ -21,7 +21,7 @@ class TerminalCursorTest {
     @Test
     @JsName("disabled_commands")
     fun `disabled commands`() {
-        val vt = VirtualTerminalInterface(AnsiLevel.NONE)
+        val vt = VirtualTerminalInterface(AnsiLevel.NONE, )
         val t = Terminal(terminalInterface = vt)
         val c = t.cursor
         forAll(
@@ -67,7 +67,7 @@ class TerminalCursorTest {
     @Test
     @JsName("disabled_cursor_show_and_hide")
     fun `disabled cursor show and hide`() {
-        val vt = VirtualTerminalInterface(AnsiLevel.NONE)
+        val vt = VirtualTerminalInterface(AnsiLevel.NONE, )
         val t = Terminal(terminalInterface = vt)
         t.cursor.hide(showOnExit = false)
         vt.buffer() shouldBe ""
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 7a6632e4..02909954 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
@@ -9,7 +9,7 @@ import kotlin.test.Test
 
 @OptIn(ExperimentalTerminalApi::class)
 class TerminalTest {
-    private val vt = VirtualTerminalInterface(width = 8)
+    private val vt = VirtualTerminalInterface(width = 8, )
     private val t = Terminal(terminalInterface = vt)
 
     @Test
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/PanelTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/PanelTest.kt
index af769424..7c3d1833 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/PanelTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/PanelTest.kt
@@ -34,7 +34,7 @@ class PanelTest : RenderingTest(width = 20) {
     @Test
     @JsName("no_border")
     fun `no border`() = checkRender(
-        Panel(Text("text\nline 2", whitespace = PRE), borderStyle = null),
+        Panel(Text("text\nline 2", whitespace = PRE), borderType = null),
         """
         |text  ⏎
         |line 2⏎
@@ -85,6 +85,47 @@ class PanelTest : RenderingTest(width = 20) {
         """
     )
 
+    @Test
+    @JsName("bottom_title")
+    fun `bottom title`() = checkRender(
+        Panel("text content", bottomTitle = "title"),
+        """
+        |╭────────────╮
+        |│text content│
+        |╰── title ───╯
+        """
+    )
+
+    @Test
+    @JsName("title_widgets")
+    fun `title widgets`() = checkRender(
+        Panel(Text("text content"), title = Text("foo\nbar"), bottomTitle = Text("foo\nbar")),
+        """
+        |     foo      ⏎
+        |╭─── bar ────╮
+        |│text content│
+        |╰─── foo ────╯
+        |     bar      ⏎
+        """
+    )
+
+    @Test
+    @JsName("bottom_align")
+    fun `bottom align`() = checkRender(
+        Panel(
+            "my panel content",
+            title = "title",
+            bottomTitle = "subtitle",
+            titleAlign = TextAlign.LEFT,
+            bottomTitleAlign = TextAlign.RIGHT
+        ),
+        """
+        |╭─ title ────────╮
+        |│my panel content│
+        |╰───── subtitle ─╯
+        """
+    )
+
     @Test
     @JsName("themed_panel")
     fun `themed panel`() = checkRender(
diff --git a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/terminal/StderrTerminalTest.kt b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/terminal/StderrTerminalTest.kt
new file mode 100644
index 00000000..56e85f57
--- /dev/null
+++ b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/terminal/StderrTerminalTest.kt
@@ -0,0 +1,30 @@
+package com.github.ajalt.mordant.terminal
+
+import com.github.ajalt.mordant.test.RenderingTest
+import io.kotest.matchers.shouldBe
+import org.junit.Rule
+import org.junit.contrib.java.lang.system.SystemErrRule
+import org.junit.contrib.java.lang.system.SystemOutRule
+import kotlin.test.Test
+
+class StderrTerminalTest : RenderingTest() {
+    @Rule
+    @JvmField
+    val stdout = SystemOutRule().enableLog().muteForSuccessfulTests()
+
+    @Rule
+    @JvmField
+    val stderr = SystemErrRule().enableLog().muteForSuccessfulTests()
+
+    @Test
+    fun stderrTerminal() {
+        val outterm = Terminal()
+        val errterm = Terminal().forStdErr()
+
+        errterm.print("foo")
+        outterm.print("bar")
+
+        stderr.logWithNormalizedLineSeparator shouldBe "foo"
+        stdout.logWithNormalizedLineSeparator shouldBe "bar"
+    }
+}