Skip to content

Commit

Permalink
Launch different phases separately depending on need (#451)
Browse files Browse the repository at this point in the history
* Launch different phases separately depending on need

A simple implementation that remembers the readings of the states when calling the layout, drawing blocks separately, and then with the help of a global observer checks which states have changed, if those that were read during layout or drawing have changed and composition is not going on now, then we run the layout or drawing phase separately.

Since composition does not occur in case of some changes, therefore, when completed in the `MosaicComposition#awaitComplete` method, we additionally expect one frame to be rendered, if necessary.

* Review fixes

* Back out AndroidX collections

---------

Co-authored-by: Jake Wharton <github@jakewharton.com>
Co-authored-by: Jake Wharton <jw@squareup.com>
  • Loading branch information
3 people committed Sep 17, 2024
1 parent 5c4284d commit 4e2ca3d
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.ObserverHandle
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.withFrameNanos
import com.github.ajalt.mordant.input.RawModeScope
import com.github.ajalt.mordant.input.enterRawMode
import com.github.ajalt.mordant.platform.MultiplatformSystem
Expand All @@ -20,6 +22,7 @@ import com.jakewharton.mosaic.layout.MosaicNode
import com.jakewharton.mosaic.ui.AnsiLevel
import com.jakewharton.mosaic.ui.BoxMeasurePolicy
import com.jakewharton.mosaic.ui.unit.IntSize
import kotlin.concurrent.Volatile
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration.Companion.milliseconds
Expand Down Expand Up @@ -49,7 +52,7 @@ internal fun renderMosaicNode(content: @Composable () -> Unit): MosaicNode {
coroutineScope = CoroutineScope(EmptyCoroutineContext),
terminalState = MordantTerminal().toMutableState(),
keyEvents = Channel(),
onEndChanges = {},
onDraw = {},
)
mosaicComposition.setContent(content)
mosaicComposition.cancel()
Expand Down Expand Up @@ -91,7 +94,7 @@ public suspend fun runMosaic(content: @Composable () -> Unit) {
coroutineScope = this,
terminalState = terminalState,
keyEvents = keyEvents,
onEndChanges = { rootNode ->
onDraw = { rootNode ->
platformDisplay(rendering.render(rootNode))
},
)
Expand Down Expand Up @@ -155,21 +158,82 @@ internal class MosaicComposition(
coroutineScope: CoroutineScope,
private val terminalState: State<Terminal>,
private val keyEvents: ReceiveChannel<KeyEvent>,
onEndChanges: (MosaicNode) -> Unit,
private val onDraw: (MosaicNode) -> Unit,
) {
private val job = Job(coroutineScope.coroutineContext[Job])
private val clock = BroadcastFrameClock()
private val composeContext: CoroutineContext = coroutineScope.coroutineContext + job + clock
val scope = CoroutineScope(composeContext)

private val applier = MosaicNodeApplier(onEndChanges)
private val applier = MosaicNodeApplier(::performLayout)
val rootNode = applier.root
private val recomposer = Recomposer(composeContext)
private val composition = Composition(applier, recomposer)

private val applyObserverHandle: ObserverHandle

private val readingStatesOnLayout = mutableSetOf<Any>()
private val readingStatesOnDraw = mutableSetOf<Any>()

private val layoutBlockStateReadObserver: (Any) -> Unit = { readingStatesOnLayout.add(it) }
private val drawBlockStateReadObserver: (Any) -> Unit = { readingStatesOnDraw.add(it) }

@Volatile
private var needLayout = false

@Volatile
private var needDraw = false

init {
GlobalSnapshotManager().ensureStarted(scope)
startRecomposer()
applyObserverHandle = registerSnapshotApplyObserver()
startListeningToNeedToLayoutOrDraw()
}

private fun performLayout(rootNode: MosaicNode) {
needLayout = false
Snapshot.observe(readObserver = layoutBlockStateReadObserver) {
rootNode.measureAndPlace()
}
performDraw(rootNode)
}

private fun performDraw(rootNode: MosaicNode) {
needDraw = false
Snapshot.observe(readObserver = drawBlockStateReadObserver) {
onDraw(rootNode)
}
}

private fun registerSnapshotApplyObserver(): ObserverHandle {
return Snapshot.registerApplyObserver { changedStates, _ ->
for (state in changedStates) {
if (needLayout && needDraw) {
break
}
if (!needLayout && readingStatesOnLayout.contains(state)) {
needLayout = true
}
if (!needDraw && readingStatesOnDraw.contains(state)) {
needDraw = true
}
}
}
}

private fun startListeningToNeedToLayoutOrDraw() {
scope.launch {
while (true) {
withFrameNanos {
when {
recomposer.currentState.value != Recomposer.State.Idle -> return@withFrameNanos
needLayout -> performLayout(applier.root)
needDraw -> performDraw(applier.root)
}
}
}
}
}

private fun startRecomposer() {
Expand Down Expand Up @@ -214,17 +278,28 @@ internal class MosaicComposition(
effectJob.children.forEach { it.join() }
recomposer.awaitIdle()

applyObserverHandle.dispose()
if (needLayout || needDraw) {
awaitFrame()
}

recomposer.close()
recomposer.join()
} finally {
applyObserverHandle.dispose() // if canceled before dispose in the try block
job.cancel()
}
}

fun cancel() {
applyObserverHandle.dispose()
recomposer.cancel()
job.cancel()
}

private suspend fun awaitFrame() {
scope.launch { withFrameNanos { } }.join()
}
}

internal class MosaicNodeApplier(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ internal class DebugRendering(
}
lastRender = systemClock.markNow()

node.measureAndPlace()
appendLine("NODES:")
appendLine(node)
appendLine()
Expand Down Expand Up @@ -99,8 +98,6 @@ internal class AnsiRendering(
}
}

node.measureAndPlace()

node.paintStatics(staticSurfaces, ansiLevel)
for (staticSurface in staticSurfaces) {
appendSurface(staticSurface)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import assertk.assertThat
import assertk.assertions.isEqualTo
import com.jakewharton.mosaic.layout.drawBehind
import com.jakewharton.mosaic.layout.offset
import com.jakewharton.mosaic.layout.size
import com.jakewharton.mosaic.layout.width
import com.jakewharton.mosaic.modifier.Modifier
import com.jakewharton.mosaic.ui.Box
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Filler
import com.jakewharton.mosaic.ui.Spacer
import com.jakewharton.mosaic.ui.Text
import com.jakewharton.mosaic.ui.unit.IntOffset
import kotlin.test.Test
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -185,4 +195,74 @@ class MosaicTest {
)
}
}

@Test fun changeInCompositionPhase() = runTest {
runMosaicTest {
setContent {
var offsetX by remember { mutableIntStateOf(0) }

Box(modifier = Modifier.width(10)) {
Filler(TestChar, modifier = Modifier.size(1).offset(offsetX, 0))
}

LaunchedEffect(Unit) {
delay(100L)
offsetX = 5
}
}

assertThat(awaitRenderSnapshot()).isEqualTo("$TestChar ")
assertThat(awaitRenderSnapshot()).isEqualTo(" $TestChar ")
}
}

@Test fun changeInLayoutPhase() = runTest {
runMosaicTest {
setContent {
var offsetX by remember { mutableIntStateOf(0) }

Box(modifier = Modifier.width(10)) {
Filler(TestChar, modifier = Modifier.size(1).offset { IntOffset(offsetX, 0) })
}

LaunchedEffect(Unit) {
delay(100L)
offsetX = 5
}
}

assertThat(awaitRenderSnapshot()).isEqualTo("$TestChar ")
assertThat(awaitRenderSnapshot()).isEqualTo(" $TestChar ")
}
}

@Test fun changeInDrawPhase() = runTest {
runMosaicTest {
setContent {
var drawAnother by remember { mutableStateOf(false) }

Box(modifier = Modifier.width(10)) {
Spacer(
modifier = Modifier
.size(1)
.drawBehind {
if (drawAnother) {
drawText(0, 0, "${TestChar + 1}")
} else {
drawText(0, 0, "$TestChar")
}
},
)
}

LaunchedEffect(Unit) {
delay(100L)
drawAnother = true
}
}

assertThat(awaitRenderSnapshot()).isEqualTo("$TestChar ")
assertThat(awaitRenderSnapshot()).isEqualTo("${TestChar + 1} ")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,13 @@ private val DefaultTestTerminalSize = IntSize(80, 20)
internal suspend fun runMosaicTest(
withAnsi: Boolean = false,
initialTerminalSize: IntSize = DefaultTestTerminalSize,
withRenderSnapshots: Boolean = true,
block: suspend TestMosaicComposition.() -> Unit,
) {
coroutineScope {
val testMosaicComposition = RealTestMosaicComposition(
coroutineScope = this,
withAnsi = withAnsi,
initialTerminalSize = initialTerminalSize,
withRenderSnapshots = withRenderSnapshots,
)
block.invoke(testMosaicComposition)
testMosaicComposition.mosaicComposition.cancel()
Expand All @@ -55,7 +53,6 @@ private class RealTestMosaicComposition(
coroutineScope: CoroutineScope,
withAnsi: Boolean,
initialTerminalSize: IntSize,
withRenderSnapshots: Boolean,
) : TestMosaicComposition {

private var contentSet = false
Expand All @@ -66,15 +63,9 @@ private class RealTestMosaicComposition(
/** Channel with the most recent snapshot, if any. */
private val renderSnapshots = Channel<String>(Channel.CONFLATED)

private val rendering: Rendering = if (withRenderSnapshots) {
AnsiRendering(ansiLevel = if (withAnsi) AnsiLevel.TRUECOLOR else AnsiLevel.NONE)
} else {
object : Rendering {
override fun render(node: MosaicNode): CharSequence {
throw UnsupportedOperationException("Rendering disabled by `withRenderSnapshots`")
}
}
}
private val rendering: Rendering = AnsiRendering(
ansiLevel = if (withAnsi) AnsiLevel.TRUECOLOR else AnsiLevel.NONE,
)

private val terminalState: MutableState<Terminal> = mutableStateOf(
Terminal(size = initialTerminalSize),
Expand All @@ -84,19 +75,17 @@ private class RealTestMosaicComposition(

val mosaicComposition = MosaicComposition(coroutineScope, terminalState, keyEvents) { rootNode ->
nodeSnapshots.trySend(rootNode)
if (withRenderSnapshots) {
val stringRender = if (withAnsi) {
rendering.render(rootNode).toString()
} else {
rendering.render(rootNode).toString()
.removeSurrounding(ansiBeginSynchronizedUpdate, ansiEndSynchronizedUpdate)
.removeSuffix("\r\n") // without last line break for simplicity
.replace(clearLine, "")
.replace(cursorUp, "")
.replace("\r\n", "\n") // CRLF to LF for simplicity
}
renderSnapshots.trySend(stringRender)
val stringRender = if (withAnsi) {
rendering.render(rootNode).toString()
} else {
rendering.render(rootNode).toString()
.removeSurrounding(ansiBeginSynchronizedUpdate, ansiEndSynchronizedUpdate)
.removeSuffix("\r\n") // without last line break for simplicity
.replace(clearLine, "")
.replace(cursorUp, "")
.replace("\r\n", "\n") // CRLF to LF for simplicity
}
renderSnapshots.trySend(stringRender)
}

override fun setContent(content: @Composable () -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import com.jakewharton.mosaic.ui.unit.constrain
import com.jakewharton.mosaic.ui.unit.constrainHeight
import com.jakewharton.mosaic.ui.unit.constrainWidth
import kotlin.math.max
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first

const val s = " "

Expand All @@ -47,14 +45,9 @@ internal val MosaicNode.size: IntSize
internal val MosaicNode.position: IntOffset
get() = IntOffset(x, y)

internal suspend fun mosaicNodesWithMeasureAndPlace(content: @Composable () -> Unit): MosaicNode {
return callbackFlow {
// if rendering is enabled, the measureAndPlace is implicitly called
runMosaicTest(withRenderSnapshots = false) {
setContent(content)
send(awaitNodeSnapshot().also { it.measureAndPlace() })
}
}.first()
// TODO: remove
internal fun mosaicNodesWithMeasureAndPlace(content: @Composable () -> Unit): MosaicNode {
return renderMosaicNode(content)
}

class Holder<T>(var value: T)
Expand Down

0 comments on commit 4e2ca3d

Please sign in to comment.