diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/components.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/components.kt index 9ea375ac..409448a4 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/components.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/components.kt @@ -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 Static( // Keep list of items which have not yet been drawn. val pending = remember { mutableStateListOf() } - 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(null) } + var seenItems by remember { mutableStateOf?>(null) } + if (seenItems !== items) { + job?.cancel() + + seenItems = items + job = scope.launch(start = UNDISPATCHED) { + items.collect { + pending.add(Item(it, drawn = false)) + } } } diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt index 3b2aab96..6c77740c 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt @@ -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) diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/nodes.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/nodes.kt index e7f78743..95758c6f 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/nodes.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/nodes.kt @@ -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(root) { diff --git a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/rendering.kt b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/rendering.kt index 9569da42..8fbbe359 100644 --- a/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/rendering.kt +++ b/mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/rendering.kt @@ -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 = 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): 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): CharSequence { + override fun render(node: MosaicNode): CharSequence { + val canvas = node.draw() + val statics = node.drawStatics() + return stringBuilder.apply { clear() diff --git a/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/AnsiRenderingTest.kt b/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/AnsiRenderingTest.kt index 02f203e9..5ac4c186 100644 --- a/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/AnsiRenderingTest.kt +++ b/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/AnsiRenderingTest.kt @@ -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(), ) } } diff --git a/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/DebugRenderingTest.kt b/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/DebugRenderingTest.kt index 9b1434c4..654ec196 100644 --- a/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/DebugRenderingTest.kt +++ b/mosaic-runtime/src/commonTest/kotlin/com/jakewharton/mosaic/DebugRenderingTest.kt @@ -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), ) } }