Repository: ajalt/mordant
Pull request #88 (2023-02-05 21:53:52)

Remove internal function usage from MarkdownRenderer
This was an exercise in Mordant's public API. Rather than working with `Lines` directly, the renderer now uses linear layouts and invoked styles. This didn't end up needing much change to the renderer, and I actually found it easier to reason about.

Major changes:

- Style resets
- A separate implementation of VerticalLayout rather than using Table. Not strictly necessary, but it was easy and should be a little faster.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f9b979a..7472f53d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,16 +4,21 @@
 ### Added
 - Add `Animation.stop()` to stop an animation without clearing it. [(#95)](https://github.com/ajalt/mordant/issues/95)
 - Animations now support resuming after a call to `stop` or `clear`. [(#94)](https://github.com/ajalt/mordant/issues/94)
+- `TextStyles.reset`, `TextStyles.resetForeground`, and `TextStyles.resetBackground` to clear existing styles.
 
 ### Fixed
 - Fix `TerminalInfo.interactive` not including `outputInteractive`
 - Fix prompts on JS targets that were broken by KT-55817 
 
+### Changed
+- **Source-incompatible change:** All boolean fields on `TextStyle` are now nullable. A null field indicates no change to the previous value when adding or nesting styles.
+- `verticalLayout{}` now defaults `TextAlign.NONE`, meaning it won't add any trailing whitespace to lines. You can return to the old behavior with `align = TextAlign.LEFT`.
+- When nesting styles, the outer style will now override inner styles at the start of a string. (e.g. `red(blue("x")) == red("x")`) 
+
 ## 2.0.0-beta12
 ### Fixed
 - Switch back to calling `stty` for `detectTerminalSize` on macOS. [(#86)](https://github.com/ajalt/mordant/issues/86)
 - `OverflowWrap` is now properly ignored when using a non-wrapping `Whitespace` value. 
-- Fix exception in ProgressBar when its width is 1 character [(#75)](https://github.com/ajalt/mordant/issues/75)
 
 ## 2.0.0-beta11
 ### Added
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt
index 8241ad35..73a60170 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt
@@ -12,7 +12,9 @@ import com.github.ajalt.mordant.internal.AnsiCodes.selector256
 import com.github.ajalt.mordant.internal.AnsiCodes.selectorRgb
 import com.github.ajalt.mordant.rendering.*
 
-internal fun renderLinesAnsi(lines: Lines, level: AnsiLevel, hyperlinks: Boolean): String = buildString {
+internal fun renderLinesAnsi(
+    lines: Lines, level: AnsiLevel, hyperlinks: Boolean,
+): String = buildString {
     for ((i, line) in lines.lines.withIndex()) {
         if (i > 0) append("\n")
 
@@ -39,8 +41,8 @@ internal fun TextStyle.invokeStyle(text: String): String {
         if (match.range.last == text.lastIndex) return@replace ""
         val new = updateStyle(style, this, match.value)
         if (match.range.first == 0) {
-            openStyle = new
-            style = new
+            openStyle = new + this // this style overrides existing style for the open tag
+            style = new + this
             return@replace ""
         }
         val tag = makeTag(style, new)
@@ -59,12 +61,14 @@ internal fun downsample(style: TextStyle, level: AnsiLevel, hyperlinks: Boolean)
             hyperlink = style.hyperlink.takeIf { hyperlinks },
             hyperlinkId = style.hyperlinkId.takeIf { hyperlinks }
         )
+
         AnsiLevel.ANSI256 -> style.copy(
             fg = style.color?.let { if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() },
             bg = style.bgColor?.let { if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() },
             hyperlink = style.hyperlink.takeIf { hyperlinks },
             hyperlinkId = style.hyperlinkId.takeIf { hyperlinks }
         )
+
         AnsiLevel.TRUECOLOR -> if (hyperlinks || style.hyperlink == null) style else style.copy(
             fg = style.color,
             bg = style.bgColor,
@@ -81,8 +85,9 @@ private fun makeTag(old: TextStyle, new: TextStyle): String {
     if (old.color != new.color) codes += new.color.toAnsi(fgColorSelector, fgColorReset, 0)
     if (old.bgColor != new.bgColor) codes += new.bgColor.toAnsi(bgColorSelector, bgColorReset, fgBgOffset)
 
-    fun style(old: Boolean, new: Boolean, open: Int, close: Int) {
-        if (old != new) codes += if (new) open else close
+    fun style(old: Boolean?, new: Boolean?, open: Int, close: Int) {
+        if (old != true && new == true) codes += open
+        else if (old == true && new != true) codes += close
     }
 
     style(old.bold, new.bold, AnsiCodes.boldOpen, AnsiCodes.boldAndDimClose)
@@ -94,7 +99,9 @@ private fun makeTag(old: TextStyle, new: TextStyle): String {
 
     val csi = if (codes.isEmpty()) "" else codes.joinToString(";", prefix = CSI, postfix = "m")
     return when {
-        old.hyperlink != new.hyperlink -> csi + makeHyperlinkTag(new.hyperlink, new.hyperlinkId)
+        old.hyperlink != new.hyperlink && new.hyperlink != HYPERLINK_RESET -> {
+            csi + makeHyperlinkTag(new.hyperlink, new.hyperlinkId)
+        }
         else -> csi
     }
 }
@@ -108,7 +115,10 @@ private fun makeHyperlinkTag(hyperlink: String?, hyperlinkId: String?): String {
 private fun Color?.toAnsi(select: Int, reset: Int, offset: Int): List<Int> {
     return when (val it = (this as? TextStyle)?.color ?: this) {
         null -> listOf(reset)
-        is Ansi16 -> listOf(it.code + offset)
+        is Ansi16 -> when (it.code) {
+            fgColorReset, bgColorReset -> listOf(reset)
+            else -> listOf(it.code + offset)
+        }
         is Ansi256 -> listOf(select, selector256, it.code)
         // The ITU T.416 spec uses colons for the rgb separator as well as extra parameters for CMYK
         // and such. Most terminals only support the semicolon form, so that's what we use.
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 688da6fc..178d3c01 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
@@ -8,7 +8,18 @@ import com.github.ajalt.mordant.widgets.Padding
 
 // All top-level vals are defined in this file to avoid initialization order issues on native.
 
-internal val DEFAULT_STYLE = TextStyle()
+internal val DEFAULT_STYLE = TextStyle(
+    color = null,
+    bgColor = null,
+    bold = false,
+    italic = false,
+    underline = false,
+    dim = false,
+    inverse = false,
+    strikethrough = false,
+    hyperlink = null,
+)
+
 internal val EMPTY_LINES = Lines(emptyList())
 internal val EMPTY_LINE: Line = Line(emptyList(), DEFAULT_STYLE)
 internal val SINGLE_SPACE = Span.space(1)
@@ -16,3 +27,4 @@ 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__"
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt
index 1ccb4343..bfcf806b 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt
@@ -128,7 +128,7 @@ private fun updateStyleWithOsc(ansi: String, existingStyle: TextStyle, defaultSt
 private fun updateStyleWithCsi(ansi: String, existingStyle: TextStyle, defaultStyle: TextStyle): TextStyle {
     if (!ansi.endsWith("m")) return existingStyle // SGR sequences end in "m", others don't affect style
 
-    // SGR sequences only contains numbers; anything else is malformed and we skip it.
+    // SGR sequences only contains numbers. Anything else is malformed, so we skip it.
     val codes = ansi.subSequence(2, ansi.length - 1)
         .split(";").mapNotNull { if (it.isEmpty()) 0 else it.toIntOrNull() }
 
@@ -151,23 +151,23 @@ private fun updateStyleWithCsi(ansi: String, existingStyle: TextStyle, defaultSt
         when (val code = codes[i]) {
             // Resets
             AnsiCodes.reset -> {
-                color = existingStyle.color
-                bgColor = existingStyle.bgColor
-                bold = existingStyle.bold
-                italic = existingStyle.italic
-                underline = existingStyle.underline
-                dim = existingStyle.dim
-                inverse = existingStyle.inverse
-                strikethrough = existingStyle.strikethrough
+                color = defaultStyle.color
+                bgColor = defaultStyle.bgColor
+                bold = defaultStyle.bold ?: false
+                italic = defaultStyle.italic ?: false
+                underline = defaultStyle.underline ?: false
+                dim = defaultStyle.dim ?: false
+                inverse = defaultStyle.inverse ?: false
+                strikethrough = defaultStyle.strikethrough ?: false
             }
             AnsiCodes.boldAndDimClose -> {
-                bold = defaultStyle.bold
-                dim = defaultStyle.dim
+                bold = defaultStyle.bold ?: false
+                dim = defaultStyle.dim ?: false
             }
-            AnsiCodes.italicClose -> italic = defaultStyle.italic
-            AnsiCodes.underlineClose -> underline = defaultStyle.underline
-            AnsiCodes.inverseClose -> inverse = defaultStyle.inverse
-            AnsiCodes.strikethroughClose -> strikethrough = defaultStyle.strikethrough
+            AnsiCodes.italicClose -> italic = defaultStyle.italic ?: false
+            AnsiCodes.underlineClose -> underline = defaultStyle.underline ?: false
+            AnsiCodes.inverseClose -> inverse = defaultStyle.inverse ?: false
+            AnsiCodes.strikethroughClose -> strikethrough = defaultStyle.strikethrough ?: false
             AnsiCodes.fgColorReset -> color = defaultStyle.color
             AnsiCodes.bgColorReset -> bgColor = defaultStyle.bgColor
             // Colors
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/Markdown.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/Markdown.kt
index 78a15479..7d489309 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/Markdown.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/Markdown.kt
@@ -19,8 +19,8 @@ class Markdown(
     private val showHtml: Boolean = false,
     private val hyperlinks: Boolean? = null,
 ) : Widget {
-    private var document: MarkdownDocument? = null
-    private fun document(t: Terminal): MarkdownDocument {
+    private var document: Widget? = null
+    private fun document(t: Terminal): Widget {
         if (document == null) {
             document = MarkdownRenderer(markdown, t.theme, showHtml, hyperlinks ?: t.info.ansiHyperLinks).render()
         }
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 f62581fb..52356256 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
@@ -1,16 +1,14 @@
 package com.github.ajalt.mordant.markdown
 
-import com.github.ajalt.mordant.internal.DEFAULT_STYLE
-import com.github.ajalt.mordant.internal.EMPTY_LINE
-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.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.horizontalLayout
 import com.github.ajalt.mordant.table.table
-import com.github.ajalt.mordant.terminal.Terminal
+import com.github.ajalt.mordant.table.verticalLayout
 import com.github.ajalt.mordant.widgets.*
 import org.intellij.markdown.IElementType
 import org.intellij.markdown.MarkdownElementTypes
@@ -24,37 +22,7 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
 import org.intellij.markdown.parser.LinkMap
 import org.intellij.markdown.parser.MarkdownParser
 
-
-internal class MarkdownDocument(private val parts: List<Widget>) : Widget {
-    override fun measure(t: Terminal, width: Int): WidthRange {
-        // This isn't measure isn't correct in some cases, e.g. task lists that place renderables on
-        // the same line. We use a RenderableBuilder instead of this class to get accurate
-        // measurements, but it makes parsing md blocks more complicated. The worst case scenario is
-        // that a table column is a little narrow, truncating the markdown.
-        return parts.maxWidthRange(t, width)
-    }
-
-    override fun render(t: Terminal, width: Int): Lines {
-        return parts.foldLines { it.render(t, width) }
-    }
-}
-
-private inline fun <T> List<T>.foldLines(transform: (T) -> Lines): Lines {
-    return fold(EMPTY_LINES) { l, r ->
-        val other = transform(r)
-        when {
-            l.lines.isEmpty() -> other
-            other.lines.isEmpty() -> l
-            else -> Lines(
-                listOf(
-                    l.lines.dropLast(1),
-                    listOf(Line(l.lines.last() + other.lines.first())),
-                    other.lines.drop(1)
-                ).flatten()
-            )
-        }
-    }
-}
+private const val NEL = "\u0085"
 
 @Suppress("PrivatePropertyName")
 internal class MarkdownRenderer(
@@ -63,8 +31,8 @@ internal class MarkdownRenderer(
     private val showHtml: Boolean,
     private val hyperlinks: Boolean,
 ) {
-    private val EOL_LINES = Lines(listOf(EMPTY_LINE, EMPTY_LINE))
-    private val EOL_TEXT = Text(EOL_LINES, whitespace = Whitespace.PRE)
+    private val EOL_TEXT = Text("\n", whitespace = Whitespace.PRE)
+    private val EMPTY_TEXT = Text("")
     private val TABLE_DELIMITER_REGEX = Regex(""":?-+:?""")
 
     @Suppress("RegExpRedundantEscape") // extra escape required on js
@@ -75,39 +43,52 @@ internal class MarkdownRenderer(
 
     private var linkMap: LinkMap? = null
 
-    fun render(): MarkdownDocument {
+    fun render(): Widget {
         val flavour: MarkdownFlavourDescriptor = GFMFlavourDescriptor()
         val parsedTree = MarkdownParser(flavour).buildMarkdownTreeFromString(input)
         return parseFile(parsedTree)
     }
 
-    private fun parseFile(node: ASTNode): MarkdownDocument {
+    private fun parseFile(node: ASTNode): Widget {
         require(node.type == MarkdownElementTypes.MARKDOWN_FILE)
         if (hyperlinks) linkMap = LinkMap.buildLinkMap(node, input)
-        return MarkdownDocument(node.children.map { parseStructure(it) })
+        return parseBlocks(node.children)
+    }
+
+    private fun parseBlocks(nodes: List<ASTNode>): Widget {
+        return verticalLayout {
+            for ((i, node) in nodes.withIndex()) {
+                // skip the extra EOL after top level block, since the layout adds it for us
+                if (node.type == MarkdownTokenTypes.EOL
+                    && i in 1 until nodes.lastIndex
+                    && nodes[i - 1].type !in listOf(MarkdownTokenTypes.EOL, MarkdownTokenTypes.WHITE_SPACE)
+                ) {
+                    continue
+                }
+                if (node.type == MarkdownTokenTypes.WHITE_SPACE) {
+                    continue
+                }
+                cell(parseStructure(node))
+            }
+        }
     }
 
     private fun parseStructure(node: ASTNode): Widget {
         return when (node.type) {
             // ElementTypes
             MarkdownElementTypes.UNORDERED_LIST -> {
-                UnorderedList(node.children
-                    .filter { it.type == MarkdownElementTypes.LIST_ITEM }
-                    .map { MarkdownDocument(it.children.drop(1).map { c -> parseStructure(c) }) }
-                )
+                UnorderedList(parseListItems(node))
             }
 
             MarkdownElementTypes.ORDERED_LIST -> {
-                OrderedList(node.children
-                    .filter { it.type == MarkdownElementTypes.LIST_ITEM }
-                    .map { MarkdownDocument(it.children.drop(1).map { c -> parseStructure(c) }) }
-                )
+                OrderedList(parseListItems(node))
             }
 
             MarkdownElementTypes.BLOCK_QUOTE -> {
-                BlockQuote(MarkdownDocument(node.children.drop(1)
-                    .filter { it.type != MarkdownTokenTypes.WHITE_SPACE }
-                    .map { parseStructure(it) })
+                BlockQuote(
+                    parseBlocks(
+                        node.children.drop(1).filter { it.type != MarkdownTokenTypes.WHITE_SPACE }
+                    )
                 )
             }
 
@@ -115,9 +96,9 @@ internal class MarkdownRenderer(
                 val start = node.children.indexOfFirst { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }
                 val end = node.children.indexOfLast { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }
                 val dropLast = if (end < 0) 0 else node.children.lastIndex - end
-                val lines = innerInlines(node, drop = start, dropLast = dropLast)
+                val inner = innerInlines(node, drop = start, dropLast = dropLast)
                 val content = Text(
-                    lines.withStyle(theme.style("markdown.code.block")),
+                    theme.style("markdown.code.block")(inner),
                     whitespace = Whitespace.PRE_WRAP
                 )
                 if (theme.flag("markdown.code.block.border")) Panel(content) else content
@@ -125,7 +106,7 @@ internal class MarkdownRenderer(
 
             MarkdownElementTypes.CODE_BLOCK -> {
                 val content = Text(
-                    innerInlines(node).withStyle(theme.style("markdown.code.block")),
+                    theme.style("markdown.code.block")(innerInlines(node)),
                     whitespace = Whitespace.PRE_WRAP
                 )
                 if (theme.flag("markdown.code.block.border")) Panel(content) else content
@@ -133,7 +114,7 @@ internal class MarkdownRenderer(
 
             MarkdownElementTypes.HTML_BLOCK -> when {
                 showHtml -> Text(innerInlines(node), whitespace = Whitespace.PRE_WRAP)
-                else -> Text(EMPTY_LINES)
+                else -> Text("")
             }
 
             MarkdownElementTypes.PARAGRAPH -> {
@@ -143,7 +124,7 @@ internal class MarkdownRenderer(
             MarkdownElementTypes.LINK_DEFINITION -> {
                 if (hyperlinks) EmptyWidget
                 else Text(
-                    parseText(node.nodeText(), theme.style("markdown.link.destination")),
+                    theme.style("markdown.link.destination")(node.nodeText()),
                     whitespace = Whitespace.NORMAL
                 )
             }
@@ -157,15 +138,6 @@ internal class MarkdownRenderer(
             MarkdownElementTypes.ATX_5 -> atxHr(theme.string("markdown.h5.rule"), theme.style("markdown.h5"), node)
             MarkdownElementTypes.ATX_6 -> atxHr(theme.string("markdown.h6.rule"), theme.style("markdown.h6"), node)
 
-            GFMTokenTypes.CHECK_BOX -> {
-                val content = CHECK_BOX_REGEX.find(node.nodeText())!!.value.removeSurrounding("[", "]")
-                val checkbox = when {
-                    content.isBlank() -> theme.string("markdown.task.unchecked")
-                    else -> theme.string("markdown.task.checked")
-                }
-                Text(parseText("$checkbox ", DEFAULT_STYLE), whitespace = Whitespace.PRE)
-            }
-
             GFMElementTypes.TABLE -> table {
                 borderType = when {
                     theme.flag("markdown.table.ascii") -> ASCII_DOUBLE_SECTION_SEPARATOR
@@ -186,50 +158,52 @@ internal class MarkdownRenderer(
                 }
             }
 
-            MarkdownTokenTypes.HORIZONTAL_RULE -> HorizontalRule(title = "")
-            MarkdownTokenTypes.EOL -> EOL_TEXT
-            MarkdownTokenTypes.WHITE_SPACE -> Text(EMPTY_LINES)
+            MarkdownTokenTypes.HORIZONTAL_RULE -> HorizontalRule()
+            MarkdownTokenTypes.EOL -> EMPTY_TEXT
+            MarkdownTokenTypes.WHITE_SPACE -> EMPTY_TEXT
             else -> error("Unexpected token when parsing structure: $node")
         }
     }
 
-    private fun parseInlines(node: ASTNode): Lines {
+    private fun parseInlines(node: ASTNode): String {
         return when (node.type) {
             // ElementTypes
             MarkdownElementTypes.CODE_SPAN -> {
                 // Trim the code as a kludge to prevent the background style from extending if the
                 // code ends with whitespace. It would be better to fix this in the text wrapping code.
-                parseText(
+                theme.style("markdown.code.span")(
                     input.substring(
                         node.children[1].startOffset,
                         node.children.last().startOffset
-                    ).trim(), theme.style("markdown.code.span")
+                    ).trim()
                 )
             }
 
             MarkdownElementTypes.EMPH -> {
-                innerInlines(node, drop = 1).withStyle(theme.style("markdown.emph"))
+                theme.style("markdown.emph")(innerInlines(node, drop = 1))
             }
 
             MarkdownElementTypes.STRONG -> {
-                innerInlines(node, drop = 2).withStyle(theme.style("markdown.strong"))
+                theme.style("markdown.strong")(innerInlines(node, drop = 2))
             }
 
             GFMElementTypes.STRIKETHROUGH -> {
-                innerInlines(node, drop = 2).withStyle(theme.style("markdown.stikethrough"))
+                theme.style("markdown.stikethrough")(innerInlines(node, drop = 2))
             }
 
             MarkdownElementTypes.LINK_TEXT -> {
-                parseText(node.children[1].nodeText(), theme.style("markdown.link.text"))
+                theme.style("markdown.link.text")(node.children[1].nodeText())
             }
 
             MarkdownElementTypes.LINK_LABEL -> {
-                parseText(node.nodeText(), theme.style("markdown.link.destination"))
+                theme.style("markdown.link.destination")(node.nodeText())
             }
 
             MarkdownElementTypes.LINK_DESTINATION -> {
-                innerInlines(node, drop = if (node.children.firstOrNull()?.type == MarkdownTokenTypes.LT) 1 else 0)
-                    .replaceStyle(theme.style("markdown.link.destination")) // the child might be TEXT or GFM_AUTOLINK
+                // the child might be TEXT or GFM_AUTOLINK
+                val drop = if (node.children.firstOrNull()?.type == MarkdownTokenTypes.LT) 1 else 0
+                val text = TextStyles.reset(innerInlines(node, drop = drop))
+                theme.style("markdown.link.destination")(text)
             }
 
             MarkdownElementTypes.INLINE_LINK -> {
@@ -250,20 +224,17 @@ internal class MarkdownRenderer(
             MarkdownTokenTypes.EMAIL_AUTOLINK,
             GFMTokenTypes.GFM_AUTOLINK,
             MarkdownTokenTypes.AUTOLINK,
-            -> parseText(node.nodeText(), theme.style("markdown.link.text"))
+            -> theme.style("markdown.link.text")(node.nodeText())
 
             MarkdownElementTypes.AUTOLINK -> innerInlines(node, drop = 1)
+            MarkdownTokenTypes.HTML_TAG -> if (showHtml) node.nodeText() else ""
 
-            MarkdownTokenTypes.HTML_TAG -> when {
-                showHtml -> parseText(node.nodeText(), DEFAULT_STYLE)
-                else -> EMPTY_LINES
-            }
             // TokenTypes
-            MarkdownTokenTypes.BLOCK_QUOTE -> EMPTY_LINES // don't render '>' delimiters in block quotes
-            MarkdownTokenTypes.CODE_LINE -> parseText(node.nodeText().drop(4), DEFAULT_STYLE)
-            MarkdownTokenTypes.HARD_LINE_BREAK -> parseText(NEL, DEFAULT_STYLE)
-            MarkdownTokenTypes.ESCAPED_BACKTICKS -> parseText("`", DEFAULT_STYLE)
-            MarkdownTokenTypes.BAD_CHARACTER -> parseText("�", DEFAULT_STYLE)
+            MarkdownTokenTypes.BLOCK_QUOTE -> "" // don't render '>' delimiters in block quotes
+            MarkdownTokenTypes.CODE_LINE -> node.nodeText().drop(4)
+            MarkdownTokenTypes.HARD_LINE_BREAK -> NEL
+            MarkdownTokenTypes.ESCAPED_BACKTICKS -> "`"
+            MarkdownTokenTypes.BAD_CHARACTER -> "�"
             MarkdownTokenTypes.BACKTICK,
             MarkdownTokenTypes.CODE_FENCE_CONTENT,
             MarkdownTokenTypes.COLON,
@@ -282,34 +253,64 @@ internal class MarkdownRenderer(
             MarkdownTokenTypes.URL,
             MarkdownTokenTypes.WHITE_SPACE,
             GFMTokenTypes.TILDE,
-            -> {
-                parseText(node.nodeText(), DEFAULT_STYLE)
-            }
+            -> node.nodeText()
 
             MarkdownTokenTypes.EOL -> {
-                // Parse the text rather than hard coding the return value to support NEL and LS.
-                Lines(parseText(node.nodeText(), DEFAULT_STYLE).lines)
+                // Return the text rather than hard coding the return value to support NEL and LS.
+                node.nodeText()
             }
 
-            else -> error("Unexpected token when parsing inlines: $node; [${node.type}:'${node.nodeText().take(10)}'}]")
+            else -> error(
+                "Unexpected token when parsing inlines: $node; [${node.type}:'${
+                    node.nodeText().take(10)
+                }'}]"
+            )
         }
     }
 
-    private fun ASTNode.nodeText(drop: Int = 0) = input.substring(startOffset + drop, endOffset - drop)
+    private fun ASTNode.nodeText(drop: Int = 0) =
+        input.substring(startOffset + drop, endOffset - drop)
 
-    private fun innerInlines(node: ASTNode, drop: Int = 0, dropLast: Int = drop): Lines {
+    private fun innerInlines(node: ASTNode, drop: Int = 0, dropLast: Int = drop): String {
         return node.children.subList(drop, node.children.size - dropLast)
-            .foldLines { parseInlines(it) }
+            .joinToString("") { parseInlines(it) }
+    }
+
+    private fun parseListItems(node: ASTNode): List<Widget> {
+        return node.children
+            .filter { it.type == MarkdownElementTypes.LIST_ITEM }
+            .map {
+                if (it.children.size > 1 && it.children[1].type == GFMTokenTypes.CHECK_BOX) {
+                    horizontalLayout {
+                        val content = CHECK_BOX_REGEX
+                            .find(it.children[1].nodeText())!!
+                            .value.removeSurrounding("[", "]")
+                        val checkboxString = when {
+                            content.isBlank() -> theme.string("markdown.task.unchecked")
+                            else -> theme.string("markdown.task.checked")
+                        }
+                        cell(checkboxString)
+                        cell(parseBlocks(it.children.drop(2)))
+                    }
+                } else {
+                    parseBlocks(it.children.drop(1))
+                }
+            }
     }
 
     private fun atxHr(bar: String, style: TextStyle, node: ASTNode): Widget {
         return when {
             node.children.size <= 1 -> EOL_TEXT
-            else -> headerHr(Text(atxContent(node).withStyle(style), whitespace = Whitespace.NORMAL), bar, style)
+            else -> headerHr(
+                Text(
+                    style(atxContent(node)),
+                    whitespace = Whitespace.NORMAL
+                ), bar, style
+            )
         }
     }
 
-    private fun atxContent(node: ASTNode): Lines {
+    private fun atxContent(node: ASTNode): String {
         val (drop, dropLast) = dropWs(node.children[1].children)
         return innerInlines(node.children[1], drop = drop, dropLast = dropLast)
     }
@@ -317,17 +318,21 @@ internal class MarkdownRenderer(
     private fun setext(bar: String, style: TextStyle, node: ASTNode): Widget {
         val (drop, dropLast) = dropWs(node.children[0].children)
         val content = innerInlines(node.children[0], drop = drop, dropLast = dropLast)
-        return headerHr(Text(content.withStyle(style), whitespace = Whitespace.NORMAL), bar, style)
+        return headerHr(Text(style(content), whitespace = Whitespace.NORMAL), bar, style)
     }
 
     private fun headerHr(content: Widget, bar: String, style: TextStyle): Widget {
-        return HorizontalRule(content, ruleCharacter = bar, ruleStyle = TextStyle(style.color, style.bgColor))
-            .withPadding { vertical = theme.dimension("markdown.header.padding") }
+        return HorizontalRule(
+            content,
+            ruleCharacter = bar,
+            ruleStyle =  TextStyle(style.color, style.bgColor)
+        ).withPadding { vertical = theme.dimension("markdown.header.padding") }
     }
 
     private fun dropWs(nodes: List<ASTNode>): Pair<Int, Int> {
         val drop = if (nodes.firstOrNull()?.type == MarkdownTokenTypes.WHITE_SPACE) 1 else 0
-        val dropLast = if (nodes.size > 1 && nodes.last().type == MarkdownTokenTypes.WHITE_SPACE) 1 else 0
+        val dropLast =
+            if (nodes.size > 1 && nodes.last().type == MarkdownTokenTypes.WHITE_SPACE) 1 else 0
         return drop to dropLast
     }
 
@@ -335,7 +340,12 @@ internal class MarkdownRenderer(
         for (child in node.children) {
             if (child.type != GFMTokenTypes.CELL) continue
             val (drop, dropLast) = dropWs(child.children)
-            cell(Text(innerInlines(child, drop = drop, dropLast = dropLast), whitespace = Whitespace.NORMAL))
+            cell(
+                Text(
+                    innerInlines(child, drop = drop, dropLast = dropLast),
+                    whitespace = Whitespace.NORMAL
+                )
+            )
         }
     }
 
@@ -349,46 +359,43 @@ internal class MarkdownRenderer(
         }
     }
 
-    private fun renderInlineLink(node: ASTNode): Lines {
-        val text = findLinkText(node)!!
+    private fun renderInlineLink(node: ASTNode): String {
+        val text = TextStyles.reset(findLinkText(node)!!)
         val dest = findLinkDest(node) ?: ""
         if (hyperlinks && dest.isNotBlank()) {
-            return text.replaceStyle(
-                theme.style("markdown.link.text").copy(hyperlink = dest, hyperlinkId = generateHyperlinkId())
-            )
+            val style = (theme.style("markdown.link.text") + TextStyle(hyperlink = dest))
+            return style(text)
         }
 
-        val parsedText = text.replaceStyle(theme.style("markdown.link.text"))
-        val parsedDest = parseText("($dest)", theme.style("markdown.link.destination"))
-        return listOf(parsedText, parsedDest).foldLines { it }
+        val parsedText = theme.style("markdown.link.text")(text)
+        val parsedDest = theme.style("markdown.link.destination")("($dest)")
+        return "$parsedText$parsedDest"
     }
 
-    private fun renderReferenceLink(node: ASTNode): Lines {
+    private fun renderReferenceLink(node: ASTNode): String {
         if (!hyperlinks) return innerInlines(node)
 
         val label = findLinkLabel(node)!!
         return when (val hyperlink = linkMap?.getLinkInfo("[$label]")?.destination?.toString()) {
             null -> innerInlines(node)
             else -> {
-                val style = theme.style("markdown.link.text")
-                    .copy(hyperlink = hyperlink, hyperlinkId = generateHyperlinkId())
-                findLinkText(node)?.replaceStyle(style) ?: parseText(label, style)
+                val style = theme.style("markdown.link.text") + TextStyle(hyperlink = hyperlink)
+                val link = findLinkText(node)?.let { TextStyles.reset(it) }
+                style(link ?: label)
             }
         }
     }
 
-    private fun renderImageLink(node: ASTNode): Lines {
+    private fun renderImageLink(node: ASTNode): String {
         val link = node.findChildOfType(MarkdownElementTypes.INLINE_LINK)
             ?: node.findChildOfType(MarkdownElementTypes.FULL_REFERENCE_LINK)
             ?: node.findChildOfType(MarkdownElementTypes.SHORT_REFERENCE_LINK)
-            ?: return EMPTY_LINES
+            ?: return ""
         val text = findLinkText(link)
             ?.takeUnless { it.isEmpty() }
-            ?: return EMPTY_LINES
-        return listOf(
-            parseText("🖼️ ", DEFAULT_STYLE),
-            text.replaceStyle(theme.style("markdown.img.alt-text"))
-        ).foldLines { it }
+            ?.let { TextStyles.reset(it) }
+            ?: return ""
+        return "🖼️ ${theme.style("markdown.img.alt-text")(text)}"
     }
 
     private fun findLinkLabel(node: ASTNode): String? {
@@ -401,9 +408,8 @@ internal class MarkdownRenderer(
             ?.nodeText()
     }
 
-    private fun findLinkText(node: ASTNode): Lines? {
-        return node.findChildOfType(MarkdownElementTypes.LINK_TEXT)
-            ?.let { innerInlines(it, drop = 1) }
+    private fun findLinkText(node: ASTNode): String? {
+        return node.findChildOfType(MarkdownElementTypes.LINK_TEXT)?.let { innerInlines(it, drop = 1) }
     }
 }
 
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Lines.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Lines.kt
index 4119e016..15f4fd55 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Lines.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Lines.kt
@@ -23,7 +23,10 @@ class Lines(
 
     fun isEmpty(): Boolean = lines.isEmpty()
 
-    internal fun withStyle(style: TextStyle?): Lines {
+    /**
+     * Create a copy of these lines with [style] combined with any existing style
+     */
+    fun withStyle(style: TextStyle?): Lines {
         return when (style) {
             null, DEFAULT_STYLE -> this
             else -> Lines(
@@ -32,7 +35,10 @@ class Lines(
         }
     }
 
-    internal fun replaceStyle(style: TextStyle?): Lines {
+    /**
+     * Create a copy of these lines with any existing styles replaced with [style]
+     */
+    fun replaceStyle(style: TextStyle?): Lines {
         return when (style) {
             null, DEFAULT_STYLE -> this
             else -> Lines(lines.map { l -> Line(l.map { it.replaceStyle(style) }, style) })
@@ -95,7 +101,6 @@ internal fun Lines.setSize(
             when {
                 width + span.cellWidth <= newWidth -> {
                     width += span.cellWidth
-
                 }
                 width == newWidth -> {
                     lines.add(Line(line.subList(0, j)))
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 873da453..7d88674e 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
@@ -12,7 +12,12 @@ import kotlin.LazyThreadSafetyMode.PUBLICATION
  * whitespace or contain no whitespace at all.
  */
 class Span private constructor(val text: String, val style: TextStyle = DEFAULT_STYLE) {
-    internal companion object {
+    companion object {
+        /**
+         * Create a Span from a string and optional style.
+         *
+         * The [text] cannot contain whitespace.
+         */
         fun word(text: String, style: TextStyle = DEFAULT_STYLE): Span {
             require(text.isNotEmpty()) { "Span text cannot be empty" }
             require(text.count { it.isWhitespace() }.let { it == 0 || it == text.length }) {
@@ -23,11 +28,14 @@ class Span private constructor(val text: String, val style: TextStyle = DEFAULT_
             return Span(text, style)
         }
 
+        /**
+         * Create a Span containing [width] number of space characters
+         */
         fun space(width: Int = 1, style: TextStyle = DEFAULT_STYLE): Span {
             return Span(" ".repeat(width), style)
         }
 
-        fun raw(text: String): Span = Span(text, DEFAULT_STYLE)
+        internal fun raw(text: String): Span = Span(text, DEFAULT_STYLE)
     }
 
     internal val cellWidth: Int by lazy(PUBLICATION) { stringCellWidth(text) }
@@ -38,4 +46,7 @@ class Span private constructor(val text: String, val style: TextStyle = DEFAULT_
 
     internal fun withStyle(style: TextStyle) = Span(text, this.style + style)
     internal fun replaceStyle(style: TextStyle) = Span(text, style)
+    override fun toString(): String {
+        return "<Span '${text}', ${style}>"
+    }
 }
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 4977ea24..c058a15a 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
@@ -2,7 +2,9 @@ package com.github.ajalt.mordant.rendering
 
 import com.github.ajalt.colormath.Color
 import com.github.ajalt.colormath.model.*
+import com.github.ajalt.mordant.internal.AnsiCodes
 import com.github.ajalt.mordant.internal.DEFAULT_STYLE
+import com.github.ajalt.mordant.internal.HYPERLINK_RESET
 import com.github.ajalt.mordant.rendering.AnsiLevel.*
 import com.github.ajalt.mordant.terminal.Terminal
 import kotlin.math.roundToInt
@@ -34,7 +36,51 @@ enum class TextStyles(val style: TextStyle) {
     italic(TextStyle(italic = true)),
     underline(TextStyle(underline = true)),
     inverse(TextStyle(inverse = true)),
-    strikethrough(TextStyle(strikethrough = true));
+    strikethrough(TextStyle(strikethrough = true)),
+
+    /**
+     * Reset the foreground color to the terminal default.
+     *
+     * ```
+     * val style = (red on blue)
+     * val backgroundOnly = style + resetForeground
+     * ```
+     */
+    resetForeground(TextStyle(Ansi16(AnsiCodes.fgColorReset))),
+
+    /**
+     * Reset the background color to the terminal default.
+     *
+     * ```
+     * val style = (red on blue)
+     * val foregroundOnly = style + resetBackground
+     * ```
+     */
+    resetBackground(TextStyle(bgColor = Ansi16(AnsiCodes.bgColorReset))),
+
+    /**
+     * Reset all styles to the terminal default.
+     *
+     * ```
+     * val style = (red on blue) + bold
+     * assert reset(style("text")) == "text"
+     * ```
+     */
+    reset(
+        TextStyle(
+            color = Ansi16(AnsiCodes.fgColorReset),
+            bgColor = Ansi16(AnsiCodes.bgColorReset),
+            bold = false,
+            italic = false,
+            underline = false,
+            dim = false,
+            inverse = false,
+            strikethrough = false,
+            hyperlink = HYPERLINK_RESET,
+        )
+    ),
+
+    ;
 
     operator fun invoke(text: String) = style.invoke(text)
     operator fun plus(other: TextStyle) = style + other
@@ -155,7 +201,7 @@ enum class TextColors(
          */
         fun gray(fraction: Number, level: AnsiLevel = TRUECOLOR): TextStyle {
             require(fraction.toFloat() in 0f..1f) { "fraction must be in the range [0, 1]" }
-            return  rgb(fraction, fraction, fraction, level)
+            return rgb(fraction, fraction, fraction, level)
         }
 
         /**
@@ -165,7 +211,8 @@ enum class TextColors(
          *
          * [x], [y], and [z] are generally in the interval `[0, 1]`
          */
-        fun xyz(x: Number, y: Number, z: Number, level: AnsiLevel = TRUECOLOR): TextStyle = color(XYZ(x, y, z), level)
+        fun xyz(x: Number, y: Number, z: Number, level: AnsiLevel = TRUECOLOR): TextStyle =
+            color(XYZ(x, y, z), level)
 
 
         /**
@@ -176,7 +223,8 @@ enum class TextColors(
          * [l] is in the interval `[0, 100]`. [a] and [b] have unlimited range,
          * but are generally in `[-100, 100]`
          */
-        fun lab(l: Number, a: Number, b: Number, level: AnsiLevel = TRUECOLOR): TextStyle = color(LAB(l, a, b), level)
+        fun lab(l: Number, a: Number, b: Number, level: AnsiLevel = TRUECOLOR): TextStyle =
+            color(LAB(l, a, b), level)
 
 
         /**
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 209dddfc..b01e3599 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
@@ -1,29 +1,65 @@
 package com.github.ajalt.mordant.rendering
 
 import com.github.ajalt.colormath.Color
+import com.github.ajalt.colormath.model.Ansi16
+import com.github.ajalt.mordant.internal.*
+import com.github.ajalt.mordant.internal.AnsiCodes
 import com.github.ajalt.mordant.internal.DEFAULT_STYLE
+import com.github.ajalt.mordant.internal.HYPERLINK_RESET
 import com.github.ajalt.mordant.internal.generateHyperlinkId
 import com.github.ajalt.mordant.internal.invokeStyle
 
+/**
+ * Styles that can be applied to text by terminals that support it.
+ *
+ * You can combine styles with [on] or [plus]
+ */
 interface TextStyle {
     val color: Color?
     val bgColor: Color?
-    val bold: Boolean
-    val italic: Boolean
-    val underline: Boolean
-    val dim: Boolean
-    val inverse: Boolean
-    val strikethrough: Boolean
+    val bold: Boolean?
+    val italic: Boolean?
+    val underline: Boolean?
+    val dim: Boolean?
+    val inverse: Boolean?
+    val strikethrough: Boolean?
     val hyperlink: String?
     val hyperlinkId: String?
 
     val bg: TextStyle
 
+    /**
+     * Create a style with the foreground color of [bg] as the background color of the new style.
+     *
+     * All other attributes will be copied from this style.
+     *
+     * ### Example
+     * ```
+     * (red on blue) == TextStyle(color=red, bgColor=blue)
+     *
+     * val style1 = TextStyle(red, bold=true)
+     * val style2 = TextStyle(blue, italic=true)
+     * (style1 on style2) == TextStyle(red, blue, bold=true)
+     * ```
+     */
     infix fun on(bg: TextStyle): TextStyle
 
     operator fun invoke(text: String): String = invokeStyle(text)
 
-    operator fun plus(other: TextStyles): TextStyle = this + other.style
+
+    /**
+     * Return a new style that combines this style with [other].
+     *
+     * And attributes that are set on both this and [other] will take the value from [other]. Any
+     * `null` values on [other] will keep the value from this style.
+     *
+     * ### Example
+     * ```
+     * val style1 = TextStyle(red, blue, bold=true, italic=true)
+     * val style2 = TextStyle(green, italic=false, dim=true)
+     * (style1 + style2) == TextStyle(green, blue, bold=true, italic=false, dim=true)
+     * ```
+     */
     operator fun plus(other: TextStyle): TextStyle {
         return when {
             this === DEFAULT_STYLE -> other
@@ -31,29 +67,30 @@ interface TextStyle {
             else -> TxtStyle(
                 color = other.color ?: color,
                 bgColor = other.bgColor ?: bgColor,
-                bold = other.bold || bold,
-                italic = other.italic || italic,
-                underline = other.underline || underline,
-                dim = other.dim || dim,
-                inverse = other.inverse || inverse,
-                strikethrough = other.strikethrough || strikethrough,
+                bold = other.bold ?: bold,
+                italic = other.italic ?: italic,
+                underline = other.underline ?: underline,
+                dim = other.dim ?: dim,
+                inverse = other.inverse ?: inverse,
+                strikethrough = other.strikethrough ?: strikethrough,
                 hyperlink = other.hyperlink ?: hyperlink,
                 hyperlinkId = other.hyperlinkId ?: hyperlinkId
             )
         }
     }
+    operator fun plus(other: TextStyles): TextStyle = this + other.style
 }
 
 @Suppress("FunctionName")
 fun TextStyle(
     color: Color? = null,
     bgColor: Color? = null,
-    bold: Boolean = false,
-    italic: Boolean = false,
-    underline: Boolean = false,
-    dim: Boolean = false,
-    inverse: Boolean = false,
-    strikethrough: Boolean = false,
+    bold: Boolean? = null,
+    italic: Boolean? = null,
+    underline: Boolean? = null,
+    dim: Boolean? = null,
+    inverse: Boolean? = null,
+    strikethrough: Boolean? = null,
     hyperlink: String? = null,
 ): TextStyle = TxtStyle(
     color = color,
@@ -71,12 +108,12 @@ fun TextStyle(
 internal data class TxtStyle(
     override val color: Color?,
     override val bgColor: Color?,
-    override val bold: Boolean,
-    override val italic: Boolean,
-    override val underline: Boolean,
-    override val dim: Boolean,
-    override val inverse: Boolean,
-    override val strikethrough: Boolean,
+    override val bold: Boolean?,
+    override val italic: Boolean?,
+    override val underline: Boolean?,
+    override val dim: Boolean?,
+    override val inverse: Boolean?,
+    override val strikethrough: Boolean?,
     override val hyperlink: String?,
     override val hyperlinkId: String?,
 ) : TextStyle {
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/WidthRange.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/WidthRange.kt
index c72fab6b..2c4dfb11 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/WidthRange.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/WidthRange.kt
@@ -2,6 +2,10 @@ package com.github.ajalt.mordant.rendering
 
 import com.github.ajalt.mordant.terminal.Terminal
 
+/**
+ * @property min The minimum width that a widget needs to render without truncation
+ * @property max The width that a widget would use if given all available space
+ */
 data class WidthRange(val min: Int, val max: Int) {
     init {
         require(min <= max) { "Range min cannot be larger than max" }
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 e46cd8d1..24e91592 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
@@ -12,9 +12,6 @@ interface CellStyleBuilderBase {
     var align: TextAlign?
     var overflowWrap: OverflowWrap?
 
-    @Deprecated("borders has been renamed to cellBorders", replaceWith = ReplaceWith("cellBorders"))
-    var borders: Borders?
-
     fun style(
         color: Color? = null,
         bgColor: Color? = null,
@@ -31,6 +28,9 @@ interface CellStyleBuilderBase {
 }
 
 interface CellStyleBuilder : CellStyleBuilderBase {
+    @Deprecated("borders has been renamed to cellBorders", replaceWith = ReplaceWith("cellBorders"))
+    var borders: Borders?
+
     /**
      * Borders around each individual cell.
      *
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 d4abc438..bc0f6efd 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
@@ -2,11 +2,8 @@ package com.github.ajalt.mordant.table
 
 import com.github.ajalt.mordant.internal.DEFAULT_STYLE
 import com.github.ajalt.mordant.rendering.*
-import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.widgets.Padding
 import com.github.ajalt.mordant.widgets.Text
-import kotlin.text.Typography.paragraph
-import kotlin.text.Typography.section
 
 
 private class CellStyleBuilderMixin : CellStyleBuilder {
@@ -88,7 +85,8 @@ internal class TableBuilderInstance : TableBuilder, CellStyleBuilder by CellStyl
 
 
 @MordantDsl
-internal class SectionBuilderInstance : SectionBuilder, CellStyleBuilder by CellStyleBuilderMixin() {
+internal class SectionBuilderInstance : SectionBuilder,
+    CellStyleBuilder by CellStyleBuilderMixin() {
     val rows = mutableListOf<RowBuilderInstance>()
     val columns = mutableMapOf<Int, CellStyleBuilder>()
     var rowStyles = listOf<TextStyle>()
@@ -158,7 +156,12 @@ internal class HorizontalLayoutBuilderInstance(
     override var spacing: Int = 1
     override var verticalAlign: VerticalAlign? = null
 
-    override fun cells(cell1: Any?, cell2: Any?, vararg cells: Any?, init: CellStyleBuilderBase.() -> Unit) {
+    override fun cells(
+        cell1: Any?,
+        cell2: Any?,
+        vararg cells: Any?,
+        init: CellStyleBuilderBase.() -> Unit,
+    ) {
         row.cells(cell1, cell2, *cells, init = init)
     }
 
@@ -176,7 +179,9 @@ internal class HorizontalLayoutBuilderInstance(
         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 }
+                cell.padding = cell.padding?.let { it.copy(left = it.left + spacing) } ?: Padding {
+                    left = spacing
+                }
             }
         }
         tableBuilder.verticalAlign = verticalAlign
@@ -195,7 +200,12 @@ internal class VerticalLayoutBuilderInstance(
     override var width: ColumnWidth = ColumnWidth.Auto
     override var spacing: Int = 0
 
-    override fun cells(cell1: Any?, cell2: Any?, vararg cells: Any?, init: CellStyleBuilderBase.() -> Unit) {
+    override fun cells(
+        cell1: Any?,
+        cell2: Any?,
+        vararg cells: Any?,
+        init: CellStyleBuilderBase.() -> Unit,
+    ) {
         section.row(cell1, init = init)
         section.row(cell2, init = init)
         cells.forEach { section.row(it, init = init) }
@@ -210,36 +220,10 @@ internal class VerticalLayoutBuilderInstance(
     }
 
     fun build(): Widget {
-        tableBuilder.padding = Padding(0)
-        tableBuilder.cellBorders = Borders.NONE
-        tableBuilder.tableBorders = Borders.NONE
-        tableBuilder.column(0) { this@column.width = this@VerticalLayoutBuilderInstance.width }
-        if (spacing > 0) {
-            for ((i, row) in section.rows.withIndex()) {
-                if (i == 0) continue
-                row.padding = row.padding?.let { it.copy(top = it.top + spacing) } ?: Padding { top = spacing }
-            }
-        }
-        val shouldTrim = width == ColumnWidth.Auto && (tableBuilder.align ?: TextAlign.NONE) == TextAlign.NONE
-        return VerticalLayout(TableLayout(tableBuilder).buildTable(), shouldTrim)
+        return VerticalLayout.fromTableBuilder(tableBuilder, spacing, width)
     }
 }
 
-private class VerticalLayout(
-    private val table: Widget,
-    private val trimLines: Boolean,
-) : Widget {
-    override fun measure(t: Terminal, width: Int): WidthRange = table.measure(t, width)
-
-    override fun render(t: Terminal, width: Int): Lines {
-        val lines = table.render(t, width)
-        if (!trimLines) return lines
-        // Copying the lines isn't the most efficient way to do avoid trailing whitespace, but it's the easiest
-        return Lines(lines.lines.map { line ->
-            line.copy(spans = line.spans.dropLastWhile { it.style == DEFAULT_STYLE && it.isWhitespace() })
-        })
-    }
-}
 
 @MordantDsl
 internal class RowBuilderInstance(
@@ -288,7 +272,12 @@ private fun getCellContent(content: Any?): CellContent {
     return CellContent.TextContent(content.toString())
 }
 
-private fun <T : CellStyleBuilder> initColumn(columns: MutableMap<Int, T>, i: Int, def: T, init: T.() -> Unit) {
+private fun <T : CellStyleBuilder> initColumn(
+    columns: MutableMap<Int, T>,
+    i: Int,
+    def: T,
+    init: T.() -> Unit,
+) {
     require(i >= 0) { "column index cannot be negative" }
     var v = columns[i]
     if (v == null) {
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 f1b420c9..ed7134a6 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
@@ -81,7 +81,7 @@ internal class TableLayout(private val table: TableBuilderInstance) {
         val borders = getStyle(Borders.ALL) { it.cellBorders }
         val padding = getStyle(Padding { horizontal = 1 }) { it.padding }
         val whitespace = getStyle(Whitespace.PRE) { it.whitespace }
-        val textAlign = getStyle(TextAlign.LEFT) { it.align }
+        val textAlign = getStyle(TextAlign.NONE) { it.align }
         val verticalAlign = getStyle(VerticalAlign.TOP) { it.verticalAlign }
         val overflowWrap = getStyle(OverflowWrap.ELLIPSES) { it.overflowWrap }
         val stripedStyle = when {
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/VerticalLayout.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/VerticalLayout.kt
new file mode 100644
index 00000000..5099b108
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/VerticalLayout.kt
@@ -0,0 +1,79 @@
+package com.github.ajalt.mordant.table
+
+import com.github.ajalt.mordant.internal.DEFAULT_STYLE
+import com.github.ajalt.mordant.internal.EMPTY_LINE
+import com.github.ajalt.mordant.rendering.*
+import com.github.ajalt.mordant.terminal.Terminal
+
+private class VerticalLayoutCell(
+    val content: Widget,
+    val style: TextStyle?,
+    val textAlign: TextAlign,
+)
+
+internal class VerticalLayout private constructor(
+    private val cells: List<VerticalLayoutCell>,
+    private val spacing: Int,
+    private val columnWidth: ColumnWidth,
+    private val textAlign: TextAlign,
+    private val hasAlignedCells: Boolean,
+) : Widget {
+    companion object {
+        fun fromTableBuilder(
+            builder: TableBuilderInstance,
+            spacing: Int,
+            columnWidth: ColumnWidth,
+        ): VerticalLayout {
+            builder.padding(0)
+            builder.cellBorders = Borders.NONE
+            builder.tableBorders = Borders.NONE
+            var aligned = false
+            val cells = TableLayout(builder).buildTable().rows.map {
+                check(it.size == 1)
+                val cell = it[0] as Cell.Content
+                aligned = aligned || (cell.textAlign !in listOf(null, TextAlign.NONE))
+                VerticalLayoutCell(cell.content, cell.style, cell.textAlign)
+            }
+            return VerticalLayout(
+                cells,
+                spacing,
+                columnWidth,
+                builder.align ?: TextAlign.NONE,
+                aligned,
+            )
+        }
+    }
+
+    init {
+        require(spacing >= 0) { "layout spacing cannot be negative" }
+    }
+
+    override fun measure(t: Terminal, width: Int): WidthRange {
+        return cells.maxWidthRange { it.content.measure(t, width) }
+    }
+
+    override fun render(t: Terminal, width: Int): Lines {
+        val renderWidth = when {
+            columnWidth is ColumnWidth.Expand -> width
+            hasAlignedCells -> measure(t, width).max
+            else -> width
+        }
+        val lines = mutableListOf<Line>()
+        val spacingLine = when (textAlign) {
+            TextAlign.NONE -> EMPTY_LINE
+            else -> Line(listOf(Span.space(renderWidth)), DEFAULT_STYLE)
+        }
+        for ((i, cell) in cells.withIndex()) {
+            if (i > 0) repeat(spacing) { lines += spacingLine }
+            var rendered = cell.content.render(t, renderWidth).withStyle(cell.style)
+            rendered = when (val w = columnWidth) {
+                ColumnWidth.Auto -> rendered
+                is ColumnWidth.Expand -> rendered.setSize(width, textAlign = cell.textAlign)
+                is ColumnWidth.Fixed -> rendered.setSize(w.width, textAlign = cell.textAlign)
+            }
+            // Cells always take up a line, even if empty
+            lines += rendered.lines.ifEmpty { listOf(EMPTY_LINE) }
+        }
+        return Lines(lines)
+    }
+}
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 bbc0f6ef..7af8c027 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
@@ -37,26 +37,26 @@ fun TerminalRecorder.outputAsHtml(): String = buildString {
 
 private fun TextStyle.asCssRules(): List<String> {
     val rules = mutableListOf<String>()
-    val (fg, bg) = if (inverse) bgColor to color else color to bgColor
+    val (fg, bg) = if (inverse == true) bgColor to color else color to bgColor
     fg?.let {
         rules.add("color: ${it.toSRGB().toHex()}")
     }
     bg?.let {
         rules.add("background-color: ${it.toSRGB().toHex()}")
     }
-    if (bold) {
+    if (bold == true) {
         rules.add("font-weight: bold")
     }
-    if (italic) {
+    if (italic == true) {
         rules.add("font-style: italic")
     }
-    if (underline) {
+    if (underline == true) {
         rules.add("text-decoration: underline")
     }
-    if (dim) {
+    if (dim == true) {
         rules.add("opacity: 0.5")
     }
-    if (strikethrough) {
+    if (strikethrough == true) {
         rules.add("text-decoration: line-through")
     }
     return rules
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 4c0a3c1b..f49c120a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -9,16 +9,14 @@ include("samples:table")
 dependencyResolutionManagement {
     versionCatalogs {
         create("libs") {
-            version("kotlin", "1.8.0")
-            version("jna", "5.12.1")
+            version("kotlin", "1.8.10")
 
             plugin("dokka", "org.jetbrains.dokka").version("1.7.20")
             library("dokka-base", "org.jetbrains.dokka:dokka-base:1.7.20")
 
             library("colormath", "com.github.ajalt.colormath:colormath:3.2.1")
-            library("markdown", "org.jetbrains:markdown:0.3.6")
-
-            library("jna-core", "net.java.dev.jna", "jna").versionRef("jna")
+            library("markdown", "org.jetbrains:markdown:0.4.0")
+            library("jna-core", "net.java.dev.jna:jna:5.13.0")
 
             // used in tests
             library("kotest", "io.kotest:kotest-assertions-core:5.5.4")


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/markdown/MarkdownTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/markdown/MarkdownTest.kt
index 0e19ac75..e9d18949 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/markdown/MarkdownTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/markdown/MarkdownTest.kt
@@ -1,15 +1,11 @@
 package com.github.ajalt.mordant.markdown
 
-import com.github.ajalt.mordant.internal.DEFAULT_STYLE
 import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.rendering.TextStyle
 import com.github.ajalt.mordant.rendering.TextStyles.*
 import com.github.ajalt.mordant.rendering.TextStyles.Companion.hyperlink
 import com.github.ajalt.mordant.rendering.Theme
 import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.test.normalizeHyperlinks
-import com.github.ajalt.mordant.widgets.LS
-import com.github.ajalt.mordant.widgets.NEL
 import io.kotest.data.blocking.forAll
 import io.kotest.data.row
 import io.kotest.matchers.shouldBe
@@ -21,9 +17,12 @@ import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
 import org.intellij.markdown.parser.MarkdownParser
 import kotlin.js.JsName
 import kotlin.test.Test
+import kotlin.test.assertEquals
 
 // Header rules are styled by removing the bold from the theme style
 private val TextStyle.colorOnly get() = TextStyle(color, bgColor)
+private  const val NEL = "\u0085"
+private  const val LS = "\u2028"
 
 class MarkdownTest {
     private val quote = Theme.Default.style("markdown.blockquote")
@@ -55,7 +54,7 @@ class MarkdownTest {
         row(linkText),
         row(linkDest),
         row(imgAlt),
-    ) { it shouldNotBe DEFAULT_STYLE }
+    ) { it shouldNotBe TextStyle() }
 
     @Test
     fun tilde() = doTest("~", "~")
@@ -70,10 +69,18 @@ Paragraph
 two
 wrapped
 line.
+
+
+
+Paragraph three line breaks.
 """, """
 Paragraph one wrapped line.
 
 Paragraph two wrapped line.
+
+
+
+Paragraph three line breaks.
 """)
 
     @Test
@@ -241,6 +248,7 @@ A ${strikethrough("strikethrough span")}.
     fun `test ordered list nested`() = doTest("""
 1. a
     1. b
+    ░
     1. c
 
 1. d
@@ -347,7 +355,6 @@ ${linkText("www.example.com/url")}
 ${linkDest("[a link]: example.com")}
 """)
 
-    @Suppress("MarkdownUnresolvedFileReference")
     @Test
     @JsName("link_with_hyperlinks")
     fun `link with hyperlinks`() = doTest("""
@@ -437,7 +444,7 @@ ${linkDest("[1]: example.png")}
 """, """
 ${(linkText + hyperlink("example.com/1"))("code")}
 
-${(linkText + hyperlink("example.com/2"))("🖼️ an image")}
+${(linkText + hyperlink("example.com/2"))("🖼️ ${imgAlt("an image")}")}
 """.normalizeHyperlinks(), hyperlinks = true)
 
     @Test
@@ -571,6 +578,23 @@ ${h6.colorOnly("    ${dim("Header Text")}    ")}
 
 """, width = 19)
 
+    @Test
+    @JsName("adjacent_headers")
+    fun `adjacent headers`() = doTest("""
+# Header 1
+
+## Header 2
+""", """
+
+${h1.colorOnly("════ ${bold("Header 1")} ═════")}
+
+
+
+${h2.colorOnly("──── ${bold("Header 2")} ─────")}
+
+""", width = 19)
+
+
     @Test
     @JsName("header_trailing_chars")
     fun `header trailing chars`() = doTest("""
@@ -648,7 +672,7 @@ line break with spaces.
 
 A hard\
   line break with a backslash.
-  
+  ░
 A *hard   ░
 line* break with emph.
 
@@ -942,7 +966,9 @@ link(c.com)
             try {
                 actual shouldBe expected.replace("░", "")
             } catch (e: Throwable) {
+                println("░".repeat(80))
                 println(actual)
+                println("░".repeat(80))
                 throw e
             }
         } catch (e: Throwable) {
@@ -974,3 +1000,12 @@ private fun StringBuilder.printNode(text: String, node: ASTNode, indent: Int = 0
         printNode(text, c, indent + 1)
     }
 }
+
+
+private fun String.normalizeHyperlinks(): String {
+    var i = 1
+    val regex = Regex(";id=([^;]+);")
+    val map = mutableMapOf<String, Int>()
+    regex.findAll(this).forEach { map.getOrPut(it.value) { i++ } }
+    return regex.replace(this) { ";id=${map[it.value]};" }
+}
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 f1782d6e..583475ea 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
@@ -5,7 +5,7 @@ import com.github.ajalt.mordant.rendering.TextAlign.CENTER
 import com.github.ajalt.mordant.rendering.TextColors.blue
 import com.github.ajalt.mordant.rendering.TextColors.red
 import com.github.ajalt.mordant.rendering.VerticalAlign.MIDDLE
-import com.github.ajalt.mordant.rendering.Whitespace.PRE
+import com.github.ajalt.mordant.rendering.Whitespace.*
 import com.github.ajalt.mordant.table.Borders.*
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.test.RenderingTest
@@ -20,11 +20,16 @@ class LinearLayoutTest : RenderingTest() {
     fun horizontalLayoutEmpty() = checkRender(
         horizontalLayout {}, "", width = 10
     )
+
     @Test
     fun horizontalLayout() = checkRender(
         horizontalLayout {
             column(1) { width = ColumnWidth.Expand() }
-            val bar = ProgressBar(indeterminate = true, showPulse = false, indeterminateStyle = TextStyle())
+            val bar = ProgressBar(
+                indeterminate = true,
+                showPulse = false,
+                indeterminateStyle = TextStyle()
+            )
             cells(1, bar, 2)
         }, """
         ░1 ━━━━━━ 2░
@@ -36,7 +41,11 @@ class LinearLayoutTest : RenderingTest() {
         horizontalLayout {
             spacing = 2
             column(1) { width = ColumnWidth.Expand() }
-            val bar = ProgressBar(indeterminate = true, showPulse = false, indeterminateStyle = TextStyle())
+            val bar = ProgressBar(
+                indeterminate = true,
+                showPulse = false,
+                indeterminateStyle = TextStyle()
+            )
             cells(1, bar, 2)
         }, """
         ░1  ━━━━  2░
@@ -49,20 +58,53 @@ class LinearLayoutTest : RenderingTest() {
         ░
         """
     )
+
     @Test
-    fun verticalLayout() = checkRender(
+    fun verticalLayoutSimple() = checkRender(
         verticalLayout {
             cell("1111")
-            cells("2", "3") { align = TextAlign.RIGHT }
+            cells("2", "3")
             cellsFrom(listOf(Text("4 4")))
         }, """
         ░1111░
+        ░2░
+        ░3░
+        ░4 4░
+        """
+    )
+
+    @Test
+    fun verticalLayoutCellAlign() = checkRender(
+        verticalLayout {
+            cell("1111")
+            cells("2", "3") { align = TextAlign.RIGHT }
+            cell(Text("4", align = TextAlign.LEFT))
+            cellsFrom(listOf(Text("5 5")))
+        }, """
+        ░1111░
         ░   2░
         ░   3░
-        ░4 4░
+        ░4   ░
+        ░5 5░
         """
     )
 
+    @Test
+    fun verticalLayoutCellAlignExpand() = checkRender(
+        verticalLayout {
+            width = ColumnWidth.Expand()
+            align = TextAlign.RIGHT
+            cell("1")
+            cell("2") { align = TextAlign.RIGHT }
+            cell(Text("3", align = TextAlign.LEFT))
+        }, """
+        ░     1░
+        ░     2░
+        ░3     ░
+        """,
+        width = 6
+    )
+
     @Test
     fun verticalLayoutPadding() = checkRender(
         verticalLayout {
@@ -78,6 +120,7 @@ class LinearLayoutTest : RenderingTest() {
         ░3░
         """
     )
+
     @Test
     fun verticalLayoutLeftAlign() = checkRender(
         verticalLayout {
@@ -95,6 +138,18 @@ class LinearLayoutTest : RenderingTest() {
         """
     )
 
+    @Test
+    fun verticalLayoutFixedTruncation() = checkRender(
+        verticalLayout {
+            whitespace = Whitespace.NOWRAP
+            cell("1111 222")
+            width = ColumnWidth.Fixed(6)
+        }, """
+        ░1111 2░
+        """
+    )
+
+
     @Test
     @JsName("nesting_horizontalLayouts_in_verticalLayouts")
     fun `nesting horizontalLayouts in verticalLayouts`() = checkRender(
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 bc49cae0..2eef2311 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
@@ -4,11 +4,13 @@ import com.github.ajalt.colormath.model.*
 import com.github.ajalt.mordant.internal.CSI
 import com.github.ajalt.mordant.internal.DEFAULT_STYLE
 import com.github.ajalt.mordant.rendering.*
+import com.github.ajalt.mordant.widgets.Text
 import io.kotest.assertions.assertSoftly
 import io.kotest.data.blocking.forAll
 import io.kotest.data.row
 import io.kotest.matchers.shouldBe
 import kotlin.js.JsName
+import kotlin.test.Ignore
 import kotlin.test.Test
 
 class TerminalColorsTest {
@@ -32,7 +34,7 @@ class TerminalColorsTest {
     @JsName("one_level_nesting_start_of_string")
     fun `one level nesting start of string`() = doTest(
         (c.blue on c.green)("${c.red("A")}B"),
-        "<31;42>A<34>B<39;49>"
+        "<34;42>AB<39;49>"
     )
 
     @Test
@@ -94,20 +96,21 @@ class TerminalColorsTest {
     }
 
     // Disabled due to codegen bug on JS/IR
-//    @Test
-//    @JsName("all_24bit_colors")
-//    fun `all 24bit colors`() = forAll(
-//        row(c.rgb("#ff00ff"), RGB("#ff00ff")),
-//        row(c.rgb(.11, .22, .33), RGB(.11, .22, .33)),
-//        row(c.hsl(.11, .22, .33), HSL(.11, .22, .33)),
-//        row(c.hsv(11, .22, .33), HSV(11, .22, .33)),
-//        row(c.cmyk(11, 22, 33, 44), CMYK(11, 22, 33, 44)),
-//        row(c.gray(0.5), RGB(.5, .5, .5)),
-//        row(c.xyz(.11, .22, .33), XYZ(.11, .22, .33)),
-//        row(c.lab(11, 22, 33), LAB(11, 22, 33)),
-//    ) { color, expected ->
-//        color.color shouldBe expected
-//    }
+    @Ignore
+    @Test
+    @JsName("all_24bit_colors")
+    fun `all 24bit colors`() = forAll(
+        row(c.rgb("#ff00ff"), RGB("#ff00ff")),
+        row(c.rgb(.11, .22, .33), RGB(.11, .22, .33)),
+        row(c.hsl(.11, .22, .33), HSL(.11, .22, .33)),
+        row(c.hsv(11, .22, .33), HSV(11, .22, .33)),
+        row(c.cmyk(11, 22, 33, 44), CMYK(11, 22, 33, 44)),
+        row(c.gray(0.5), RGB(.5, .5, .5)),
+        row(c.xyz(.11, .22, .33), XYZ(.11, .22, .33)),
+        row(c.lab(11, 22, 33), LAB(11, 22, 33)),
+    ) { color, expected ->
+        color.color shouldBe expected
+    }
 
     @Test
     @JsName("all_styles")
@@ -167,6 +170,9 @@ class TerminalColorsTest {
         try {
             actual.replace(CSI, "<").replace("m", ">") shouldBe expected
         } catch (e: Throwable) {
+            println("Expected:")
+            println(expected.replace("<", CSI).replace(">", "m"))
+            println("Actual:")
             println(actual)
             throw e
         }
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt
index eafc24b5..0e9bebe8 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt
@@ -93,6 +93,24 @@ class TextTest : RenderingTest() {
     ░${OSC}8;id=1;foo.com${ST}foo.${OSC}8;id=2;bar.com${ST}bar${OSC}8;id=1;foo.com$ST.com${OSC}8;;$ST/baz
     """, width = 79) { it.normalizeHyperlinks() }
 
+    @Test
+    @JsName("replacing_whole_string_color")
+    fun `replacing whole string color`() = checkRender(
+        Text((green on gray)((red on blue)("text"))),
+        (green on gray)("text")
+    )
+
+    @Test
+    fun resets() = forAll(
+        row(TextStyles.resetForeground, blue.bg),
+        row(TextStyles.resetBackground, red),
+        row(TextStyles.reset, DEFAULT_STYLE),
+    ) { style, expected ->
+        checkRender(Text(style((red on blue)("text"))), expected("text"))
+    }
+
+//    fun `inner resets`() = TODO()
+
     @Test
     @JsName("hyperlink_one_line")
     fun `hyperlink one line`() = doHyperlinkTest("This is a link",