Include node tree in debug output

This commit is contained in:
Jake Wharton
2023-02-25 13:42:35 -05:00
committed by Jake Wharton
parent ac258f9d6f
commit 6de6f035b7
6 changed files with 114 additions and 55 deletions

View File

@ -3,11 +3,17 @@ package com.jakewharton.mosaic
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@Composable
public fun Text(
@ -135,9 +141,19 @@ public fun <T> Static(
// Keep list of items which have not yet been drawn.
val pending = remember { mutableStateListOf<Item>() }
LaunchedEffect(items) {
items.collect {
pending.add(Item(it, drawn = false))
// We use all this manual scope/job/launch stuff instead of a LaunchedEffect so that
// the items collection occurs within the same recomposition as it is created.
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var seenItems by remember { mutableStateOf<Flow<T>?>(null) }
if (seenItems !== items) {
job?.cancel()
seenItems = items
job = scope.launch(start = UNDISPATCHED) {
items.collect {
pending.add(Item(it, drawn = false))
}
}
}

View File

@ -21,7 +21,7 @@ import kotlinx.coroutines.yield
*/
private const val debugOutput = false
public fun renderMosaic(content: @Composable () -> Unit): String {
internal fun mosaicNodes(content: @Composable () -> Unit): MosaicNode {
val clock = BroadcastFrameClock()
val job = Job()
val composeContext = clock + job
@ -35,10 +35,12 @@ public fun renderMosaic(content: @Composable () -> Unit): String {
job.cancel()
composition.dispose()
val canvas = rootNode.draw()
val statics = rootNode.drawStatics()
val render = AnsiRendering().render(canvas, statics)
return rootNode
}
public fun renderMosaic(content: @Composable () -> Unit): String {
val rootNode = mosaicNodes(content)
val render = AnsiRendering().render(rootNode)
return render.toString()
}
@ -78,9 +80,7 @@ public suspend fun runMosaic(body: suspend MosaicScope.() -> Unit): Unit = corou
hasFrameWaiters = false
clock.sendFrame(0L) // Frame time value is not used by Compose runtime.
val canvas = rootNode.draw()
val statics = rootNode.drawStatics()
val render = rendering.render(canvas, statics)
val render = rendering.render(rootNode)
platformDisplay(render)
displaySignal?.complete(Unit)

View File

@ -106,7 +106,7 @@ internal class RootNode : ContainerNode() {
return children.flatMap(MosaicNode::drawStatics)
}
override fun toString() = children.joinToString(prefix = "Box(", postfix = ")")
override fun toString() = children.joinToString(separator = "\n")
}
internal class LinearNode(var isRow: Boolean = true) : ContainerNode() {
@ -192,7 +192,7 @@ internal class LinearNode(var isRow: Boolean = true) : ContainerNode() {
override fun toString() = buildString {
append(if (isRow) "Row" else "Column")
children.joinTo(this, prefix = "(", postfix = ")")
children.joinTo(this, prefix = "()") { "\n" + it.toString().prependIndent(" ") }
}
}
@ -236,7 +236,7 @@ internal class StaticNode(
return statics
}
override fun toString() = box.children.joinToString(prefix = "Static(", postfix = ")")
override fun toString() = box.children.joinToString(prefix = "Static()") { "\n" + it.toString().prependIndent(" ") }
}
internal class MosaicNodeApplier(root: RootNode) : AbstractApplier<MosaicNode>(root) {

View File

@ -6,12 +6,12 @@ import kotlin.time.TimeSource
internal interface Rendering {
/**
* Render [canvas] and [statics] to a single string for display.
* Render [node] to a single string for display.
*
* Note: The returned [CharSequence] is only valid until the next call to this function,
* as implementations are free to reuse buffers across invocations.
*/
fun render(canvas: TextCanvas, statics: List<TextCanvas> = emptyList()): CharSequence
fun render(node: MosaicNode): CharSequence
}
@ExperimentalTime
@ -20,7 +20,7 @@ internal class DebugRendering(
) : Rendering {
private var lastRender: TimeMark? = null
override fun render(canvas: TextCanvas, statics: List<TextCanvas>): CharSequence {
override fun render(node: MosaicNode): CharSequence {
return buildString {
lastRender?.let { lastRender ->
repeat(50) { append('~') }
@ -29,11 +29,21 @@ internal class DebugRendering(
}
lastRender = systemClock.markNow()
for (static in statics) {
appendLine(static.render())
}
val canvas = node.draw()
val statics = node.drawStatics()
appendLine(canvas.render())
appendLine("NODES:")
appendLine(node)
appendLine()
if (statics.isNotEmpty()) {
appendLine("STATIC:")
for (static in statics) {
appendLine(static)
}
appendLine()
}
appendLine("OUTPUT:")
appendLine(canvas)
}
}
}
@ -42,7 +52,10 @@ internal class AnsiRendering : Rendering {
private val stringBuilder = StringBuilder(100)
private var lastHeight = 0
override fun render(canvas: TextCanvas, statics: List<TextCanvas>): CharSequence {
override fun render(node: MosaicNode): CharSequence {
val canvas = node.draw()
val statics = node.drawStatics()
return stringBuilder.apply {
clear()

View File

@ -7,9 +7,11 @@ class AnsiRenderingTest {
private val rendering = AnsiRendering()
@Test fun firstRender() {
val helloCanvas = TextSurface(6, 2).apply {
write(0, 0, "Hello")
write(1, 0, "World!")
val hello = mosaicNodes {
Column {
Text("Hello")
Text("World!")
}
}
// TODO We should not draw trailing whitespace.
@ -18,14 +20,16 @@ class AnsiRenderingTest {
|Hello$s
|World!
|""".trimMargin(),
rendering.render(helloCanvas).toString(),
rendering.render(hello).toString(),
)
}
@Test fun subsequentLongerRenderClearsRenderedLines() {
val firstCanvas = TextSurface(6, 2).apply {
write(0, 0, "Hello")
write(1, 0, "World!")
val first = mosaicNodes {
Column {
Text("Hello")
Text("World!")
}
}
assertEquals(
@ -33,14 +37,16 @@ class AnsiRenderingTest {
|Hello$s
|World!
|""".trimMargin(),
rendering.render(firstCanvas).toString(),
rendering.render(first).toString(),
)
val secondCanvas = TextSurface(3, 4).apply {
write(0, 0, "Hel")
write(1, 0, "lo")
write(2, 0, "Wor")
write(3, 0, "ld!")
val second = mosaicNodes {
Column {
Text("Hel")
Text("lo")
Text("Wor")
Text("ld!")
}
}
assertEquals(
@ -50,16 +56,18 @@ class AnsiRenderingTest {
|Wor
|ld!
|""".trimMargin(),
rendering.render(secondCanvas).toString(),
rendering.render(second).toString(),
)
}
@Test fun subsequentShorterRenderClearsRenderedLines() {
val firstCanvas = TextSurface(3, 4).apply {
write(0, 0, "Hel")
write(1, 0, "lo")
write(2, 0, "Wor")
write(3, 0, "ld!")
val first = mosaicNodes {
Column {
Text("Hel")
Text("lo")
Text("Wor")
Text("ld!")
}
}
assertEquals(
@ -69,12 +77,14 @@ class AnsiRenderingTest {
|Wor
|ld!
|""".trimMargin(),
rendering.render(firstCanvas).toString(),
rendering.render(first).toString(),
)
val secondCanvas = TextSurface(6, 2).apply {
write(0, 0, "Hello")
write(1, 0, "World!")
val second = mosaicNodes {
Column {
Text("Hello")
Text("World!")
}
}
assertEquals(
@ -84,7 +94,7 @@ class AnsiRenderingTest {
|$clearLine
|$clearLine$cursorUp
""".trimMargin(),
rendering.render(secondCanvas).toString(),
rendering.render(second).toString(),
)
}
}

View File

@ -5,6 +5,7 @@ import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime
import kotlin.time.TestTimeSource
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalTime::class)
class DebugRenderingTest {
@ -12,38 +13,57 @@ class DebugRenderingTest {
private val rendering = DebugRendering(timeSource)
@Test fun framesIncludeStatics() {
val helloCanvas = TextSurface(5, 1)
helloCanvas.write(0, 0, "Hello")
val staticCanvas = TextSurface(6, 1)
staticCanvas.write(0, 0, "Static")
val nodes = mosaicNodes {
Text("Hello")
Static(flowOf("Static")) {
Text(it)
}
}
assertEquals(
"""
|NODES:
|Text("Hello", x=0, y=0, width=5, height=1)
|Static()
| Text("Static", x=0, y=0, width=6, height=1)
|
|STATIC:
|Static
|
|OUTPUT:
|Hello
|""".trimMargin(),
rendering.render(helloCanvas, listOf(staticCanvas)),
rendering.render(nodes),
)
}
@Test fun framesAfterFirstHaveTimeHeader() {
val helloCanvas = TextSurface(5, 1)
helloCanvas.write(0, 0, "Hello")
val hello = mosaicNodes {
Text("Hello")
}
assertEquals(
"""
|NODES:
|Text("Hello", x=0, y=0, width=5, height=1)
|
|OUTPUT:
|Hello
|""".trimMargin(),
rendering.render(helloCanvas),
rendering.render(hello),
)
timeSource += 100.milliseconds
assertEquals(
"""
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +100ms
|NODES:
|Text("Hello", x=0, y=0, width=5, height=1)
|
|OUTPUT:
|Hello
|""".trimMargin(),
rendering.render(helloCanvas),
rendering.render(hello),
)
}
}