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.Composable
import androidx.compose.runtime.ComposeNode import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.flow.Flow
import kotlinx.coroutines.launch
@Composable @Composable
public fun Text( public fun Text(
@ -135,9 +141,19 @@ public fun <T> Static(
// Keep list of items which have not yet been drawn. // Keep list of items which have not yet been drawn.
val pending = remember { mutableStateListOf<Item>() } val pending = remember { mutableStateListOf<Item>() }
LaunchedEffect(items) { // We use all this manual scope/job/launch stuff instead of a LaunchedEffect so that
items.collect { // the items collection occurs within the same recomposition as it is created.
pending.add(Item(it, drawn = false)) 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 private const val debugOutput = false
public fun renderMosaic(content: @Composable () -> Unit): String { internal fun mosaicNodes(content: @Composable () -> Unit): MosaicNode {
val clock = BroadcastFrameClock() val clock = BroadcastFrameClock()
val job = Job() val job = Job()
val composeContext = clock + job val composeContext = clock + job
@ -35,10 +35,12 @@ public fun renderMosaic(content: @Composable () -> Unit): String {
job.cancel() job.cancel()
composition.dispose() composition.dispose()
val canvas = rootNode.draw() return rootNode
val statics = rootNode.drawStatics() }
val render = AnsiRendering().render(canvas, statics)
public fun renderMosaic(content: @Composable () -> Unit): String {
val rootNode = mosaicNodes(content)
val render = AnsiRendering().render(rootNode)
return render.toString() return render.toString()
} }
@ -78,9 +80,7 @@ public suspend fun runMosaic(body: suspend MosaicScope.() -> Unit): Unit = corou
hasFrameWaiters = false hasFrameWaiters = false
clock.sendFrame(0L) // Frame time value is not used by Compose runtime. clock.sendFrame(0L) // Frame time value is not used by Compose runtime.
val canvas = rootNode.draw() val render = rendering.render(rootNode)
val statics = rootNode.drawStatics()
val render = rendering.render(canvas, statics)
platformDisplay(render) platformDisplay(render)
displaySignal?.complete(Unit) displaySignal?.complete(Unit)

View File

@ -106,7 +106,7 @@ internal class RootNode : ContainerNode() {
return children.flatMap(MosaicNode::drawStatics) 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() { internal class LinearNode(var isRow: Boolean = true) : ContainerNode() {
@ -192,7 +192,7 @@ internal class LinearNode(var isRow: Boolean = true) : ContainerNode() {
override fun toString() = buildString { override fun toString() = buildString {
append(if (isRow) "Row" else "Column") 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 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) { internal class MosaicNodeApplier(root: RootNode) : AbstractApplier<MosaicNode>(root) {

View File

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

View File

@ -7,9 +7,11 @@ class AnsiRenderingTest {
private val rendering = AnsiRendering() private val rendering = AnsiRendering()
@Test fun firstRender() { @Test fun firstRender() {
val helloCanvas = TextSurface(6, 2).apply { val hello = mosaicNodes {
write(0, 0, "Hello") Column {
write(1, 0, "World!") Text("Hello")
Text("World!")
}
} }
// TODO We should not draw trailing whitespace. // TODO We should not draw trailing whitespace.
@ -18,14 +20,16 @@ class AnsiRenderingTest {
|Hello$s |Hello$s
|World! |World!
|""".trimMargin(), |""".trimMargin(),
rendering.render(helloCanvas).toString(), rendering.render(hello).toString(),
) )
} }
@Test fun subsequentLongerRenderClearsRenderedLines() { @Test fun subsequentLongerRenderClearsRenderedLines() {
val firstCanvas = TextSurface(6, 2).apply { val first = mosaicNodes {
write(0, 0, "Hello") Column {
write(1, 0, "World!") Text("Hello")
Text("World!")
}
} }
assertEquals( assertEquals(
@ -33,14 +37,16 @@ class AnsiRenderingTest {
|Hello$s |Hello$s
|World! |World!
|""".trimMargin(), |""".trimMargin(),
rendering.render(firstCanvas).toString(), rendering.render(first).toString(),
) )
val secondCanvas = TextSurface(3, 4).apply { val second = mosaicNodes {
write(0, 0, "Hel") Column {
write(1, 0, "lo") Text("Hel")
write(2, 0, "Wor") Text("lo")
write(3, 0, "ld!") Text("Wor")
Text("ld!")
}
} }
assertEquals( assertEquals(
@ -50,16 +56,18 @@ class AnsiRenderingTest {
|Wor |Wor
|ld! |ld!
|""".trimMargin(), |""".trimMargin(),
rendering.render(secondCanvas).toString(), rendering.render(second).toString(),
) )
} }
@Test fun subsequentShorterRenderClearsRenderedLines() { @Test fun subsequentShorterRenderClearsRenderedLines() {
val firstCanvas = TextSurface(3, 4).apply { val first = mosaicNodes {
write(0, 0, "Hel") Column {
write(1, 0, "lo") Text("Hel")
write(2, 0, "Wor") Text("lo")
write(3, 0, "ld!") Text("Wor")
Text("ld!")
}
} }
assertEquals( assertEquals(
@ -69,12 +77,14 @@ class AnsiRenderingTest {
|Wor |Wor
|ld! |ld!
|""".trimMargin(), |""".trimMargin(),
rendering.render(firstCanvas).toString(), rendering.render(first).toString(),
) )
val secondCanvas = TextSurface(6, 2).apply { val second = mosaicNodes {
write(0, 0, "Hello") Column {
write(1, 0, "World!") Text("Hello")
Text("World!")
}
} }
assertEquals( assertEquals(
@ -84,7 +94,7 @@ class AnsiRenderingTest {
|$clearLine |$clearLine
|$clearLine$cursorUp |$clearLine$cursorUp
""".trimMargin(), """.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.Duration.Companion.milliseconds
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.TestTimeSource import kotlin.time.TestTimeSource
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
class DebugRenderingTest { class DebugRenderingTest {
@ -12,38 +13,57 @@ class DebugRenderingTest {
private val rendering = DebugRendering(timeSource) private val rendering = DebugRendering(timeSource)
@Test fun framesIncludeStatics() { @Test fun framesIncludeStatics() {
val helloCanvas = TextSurface(5, 1) val nodes = mosaicNodes {
helloCanvas.write(0, 0, "Hello") Text("Hello")
val staticCanvas = TextSurface(6, 1) Static(flowOf("Static")) {
staticCanvas.write(0, 0, "Static") Text(it)
}
}
assertEquals( 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 |Static
|
|OUTPUT:
|Hello |Hello
|""".trimMargin(), |""".trimMargin(),
rendering.render(helloCanvas, listOf(staticCanvas)), rendering.render(nodes),
) )
} }
@Test fun framesAfterFirstHaveTimeHeader() { @Test fun framesAfterFirstHaveTimeHeader() {
val helloCanvas = TextSurface(5, 1) val hello = mosaicNodes {
helloCanvas.write(0, 0, "Hello") Text("Hello")
}
assertEquals( assertEquals(
""" """
|NODES:
|Text("Hello", x=0, y=0, width=5, height=1)
|
|OUTPUT:
|Hello |Hello
|""".trimMargin(), |""".trimMargin(),
rendering.render(helloCanvas), rendering.render(hello),
) )
timeSource += 100.milliseconds timeSource += 100.milliseconds
assertEquals( assertEquals(
""" """
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +100ms |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +100ms
|NODES:
|Text("Hello", x=0, y=0, width=5, height=1)
|
|OUTPUT:
|Hello |Hello
|""".trimMargin(), |""".trimMargin(),
rendering.render(helloCanvas), rendering.render(hello),
) )
} }
} }