Repository: ajalt/mordant (kotlin)

Issue:
Table rendering on non-interactive output
Tables are rendered poorly on non-interactive output (e.g. redirect to file). The default width of 79 causes unwanted truncation of content.

It would be nice to be able to set "infinite" width for tables, so that they expand to fit all content in every cell. Or even better an option to render tables as CSV, JSON or some such format. That would be useful when redirecting to file or process for machine parsing.

I would like to be able to use the same Table API for both nice looking human-readable tables for interactive output, and for machine-readable format for non-interactive output.

Pull request description:
Fixes #140

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eed16680d..d7350b705 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
 - Added tvOS and watchOS native targets to all modules except the new `mordant-markdown` module.
 - Added ability to control raw mode with the `TerminalRecorder`.
 - Added support for unicode input in raw mode.
+- Added `nonInteractiveWidth` and `nonInteractiveHeight` to `Terminal` terminal constructor to set a different width when the terminal is not interactive (e.g. when redirecting output to a file) [(#140)](https://github.com/ajalt/mordant/issues/140)
 
 ### Changed
 - **Breaking Change** Moved `Terminal.info.width` and `height` to `Terminal.size.width` and `height`.
diff --git a/mordant/api/mordant.api b/mordant/api/mordant.api
index afb0e1c6d..8742dca8d 100644
--- a/mordant/api/mordant.api
+++ b/mordant/api/mordant.api
@@ -1186,8 +1186,8 @@ public final class com/github/ajalt/mordant/terminal/StringPrompt : com/github/a
 }
 
 public final class com/github/ajalt/mordant/terminal/Terminal {
-	public fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;Lcom/github/ajalt/mordant/terminal/TerminalInterface;)V
-	public synthetic fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;Lcom/github/ajalt/mordant/terminal/TerminalInterface;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;Lcom/github/ajalt/mordant/terminal/TerminalInterface;)V
+	public synthetic fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;Lcom/github/ajalt/mordant/terminal/TerminalInterface;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public final fun danger (Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;Z)V
 	public static synthetic fun danger$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;ZILjava/lang/Object;)V
 	public final fun getColors ()Lcom/github/ajalt/mordant/terminal/TerminalColors;
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 82d83fd1c..02ac9a6ff 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
@@ -18,19 +18,27 @@ import com.github.ajalt.mordant.widgets.Text
  * `interactive`.
  */
 class Terminal private constructor(
+    /** The theme to use when rendering widgets */
     val theme: Theme,
+    /** The number of spaces to use when printing tab characters. */
     val tabWidth: Int,
+    /** The interface to use to interact with the system terminal. */
     val terminalInterface: TerminalInterface,
     private val forceWidth: Int?,
     private val forceHeight: Int?,
+    private val nonInteractiveWidth: Int?,
+    private val nonInteractiveHeight: Int?,
     /** The terminal capabilities that were detected or set in the constructor. */
     val info: TerminalInfo,
 ) {
+
     /**
      * @param ansiLevel The level of color support to use, or `null` to detect the level of the current terminal
      * @param theme The theme to use for widgets and styles like [success]
      * @param width The width to render widget and wrap text, or `null` to detect the current width.
      * @param height The height of the terminal to use when rendering widgets, or `null` to detect the current width.
+     * @param nonInteractiveWidth The width to use when the terminal is not interactive, or `null` to use the default of 79 columns.
+     * @param nonInteractiveHeight The height to use when the terminal is not interactive, or `null` to use the default of 24 rows.
      * @param hyperlinks whether to render hyperlinks using ANSI codes, or `null` to detect the capability
      * @param tabWidth The number of spaces to use for `\t` characters
      * @param interactive Set to true to always use color codes, even if stdout is redirected to a
@@ -43,6 +51,8 @@ class Terminal private constructor(
         theme: Theme = Theme.Default,
         width: Int? = null,
         height: Int? = null,
+        nonInteractiveWidth: Int? = null,
+        nonInteractiveHeight: Int? = null,
         hyperlinks: Boolean? = null,
         tabWidth: Int = 8,
         interactive: Boolean? = null,
@@ -53,6 +63,8 @@ class Terminal private constructor(
         terminalInterface = terminalInterface,
         forceWidth = width,
         forceHeight = height,
+        nonInteractiveWidth = nonInteractiveWidth,
+        nonInteractiveHeight = nonInteractiveHeight,
         info = terminalInterface.info(
             ansiLevel = ansiLevel,
             hyperlinks = hyperlinks,
@@ -65,7 +77,15 @@ class Terminal private constructor(
         MppAtomicRef(emptyList())
 
     private val atomicSize: MppAtomicRef<Size> =
-        MppAtomicRef(terminalInterface.detectSize(forceWidth, forceHeight))
+        MppAtomicRef(
+            terminalInterface.detectSize(
+                info,
+                forceWidth,
+                forceHeight,
+                nonInteractiveWidth,
+                nonInteractiveHeight
+            )
+        )
 
 
     /**
@@ -81,7 +101,15 @@ class Terminal private constructor(
      * This is called automatically whenever you print to the terminal.
      */
     fun updateSize(): Size {
-        return atomicSize.update { terminalInterface.detectSize(forceWidth, forceHeight) }.second
+        return atomicSize.update {
+            terminalInterface.detectSize(
+                info,
+                forceWidth,
+                forceHeight,
+                nonInteractiveWidth,
+                nonInteractiveHeight
+            )
+        }.second
     }
 
     /**
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 1ad4a69ba..186d69d91 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
@@ -200,11 +200,23 @@ object TerminalDetection {
     }
 }
 
-internal fun TerminalInterface.detectSize(width: Int?, height: Int?): Size {
+private const val DEFAULT_WIDTH = 79
+private const val DEFAULT_HEIGHT = 24
+internal fun TerminalInterface.detectSize(
+    info: TerminalInfo,
+    width: Int?,
+    height: Int?,
+    nonInteractiveWidth: Int?,
+    nonInteractiveHeight: Int?,
+): Size {
     if (width != null && height != null) return Size(width, height)
+    if (!info.outputInteractive) return Size(
+        nonInteractiveWidth ?: width ?: DEFAULT_WIDTH,
+        nonInteractiveHeight ?: height ?: DEFAULT_HEIGHT
+    )
     val detected = getTerminalSize() ?: Size(
-        width = (getEnv("COLUMNS")?.toIntOrNull() ?: 79),
-        height = (getEnv("LINES")?.toIntOrNull() ?: 24)
+        width = (getEnv("COLUMNS")?.toIntOrNull() ?: DEFAULT_WIDTH),
+        height = (getEnv("LINES")?.toIntOrNull() ?: DEFAULT_HEIGHT)
     )
     return Size(width = width ?: detected.width, height = height ?: detected.height)
 }


Test patch:
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 6723f5d7d..84a21cd9e 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
@@ -3,6 +3,8 @@ package com.github.ajalt.mordant.terminal
 import com.github.ajalt.mordant.rendering.TextAlign
 import com.github.ajalt.mordant.rendering.TextColors.cyan
 import com.github.ajalt.mordant.rendering.Whitespace
+import io.kotest.data.blocking.forAll
+import io.kotest.data.row
 import io.kotest.matchers.shouldBe
 import kotlin.js.JsName
 import kotlin.test.Test
@@ -84,4 +86,23 @@ class TerminalTest {
         |${cyan("    wrap")}
         """.trimMargin()
     }
+
+    @[Test JsName("width_override")]
+    fun `width override`() = forAll(
+        row(1, 2, true, 1),
+        row(1, 2, false, 2),
+        row(null, 2, true, 3),
+        row(null, 2, false, 2),
+        row(1, null, true, 1),
+        row(1, null, false, 1),
+        row(null, null, true, 3),
+    ) { width, niWidth, interactive, expected ->
+        val vt = TerminalRecorder(width = 3, outputInteractive = interactive)
+        val t = Terminal(
+            terminalInterface = vt,
+            width = width,
+            nonInteractiveWidth = niWidth
+        )
+        t.size.width shouldBe expected
+    }
 }
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 6e9a1323d..420865985 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
@@ -19,7 +19,14 @@ abstract class RenderingTest(
         theme: Theme = Theme.Default,
         transformActual: (String) -> String = { it },
     ) {
-        val t = Terminal(AnsiLevel.TRUECOLOR, theme, width, height, hyperlinks, tabWidth)
+        val t = Terminal(
+            ansiLevel = AnsiLevel.TRUECOLOR,
+            theme = theme,
+            width = width,
+            height = height,
+            hyperlinks = hyperlinks,
+            tabWidth = tabWidth
+        )
         val actual = transformActual(t.render(widget))
         actual.shouldMatchRender(expected, trimMargin)
     }