Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change runMosaic to support effects #284

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions mosaic-runtime/api/mosaic-runtime.api
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ public final class com/jakewharton/mosaic/MosaicKt {
public static final fun runMosaic (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public abstract interface class com/jakewharton/mosaic/MosaicScope : kotlinx/coroutines/CoroutineScope {
public abstract fun setContent (Lkotlin/jvm/functions/Function2;)V
}

public final class com/jakewharton/mosaic/Terminal {
public static final field $stable I
public synthetic fun <init> (JLkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
127 changes: 52 additions & 75 deletions mosaic-runtime/src/commonMain/kotlin/com/jakewharton/mosaic/mosaic.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ import com.github.ajalt.mordant.terminal.Terminal as MordantTerminal
import com.jakewharton.mosaic.layout.MosaicNode
import com.jakewharton.mosaic.ui.BoxMeasurePolicy
import com.jakewharton.mosaic.ui.unit.IntSize
import kotlin.coroutines.CoroutineContext
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield

/**
* True for a debug-like output that renders each "frame" on its own with a timestamp delta.
Expand Down Expand Up @@ -51,11 +52,7 @@ public fun renderMosaic(content: @Composable () -> Unit): String {
return render.toString()
}

public interface MosaicScope : CoroutineScope {
public fun setContent(content: @Composable () -> Unit)
}

public suspend fun runMosaic(body: suspend MosaicScope.() -> Unit): Unit = coroutineScope {
public suspend fun runMosaic(content: @Composable () -> Unit): Unit = coroutineScope {
val terminal = MordantTerminal()

val rendering = if (debugOutput) {
Expand All @@ -65,46 +62,31 @@ public suspend fun runMosaic(body: suspend MosaicScope.() -> Unit): Unit = corou
AnsiRendering(ansiLevel = terminal.info.ansiLevel.toMosaicAnsiLevel())
}

var hasFrameWaiters = false
val clock = BroadcastFrameClock {
hasFrameWaiters = true
}

val clock = BroadcastFrameClock()
val job = Job(coroutineContext[Job])
val composeContext = coroutineContext + clock + job

val rootNode = createRootNode()
var displaySignal: CompletableDeferred<Unit>? = null
val applier = MosaicNodeApplier(rootNode) {
val render = rendering.render(rootNode)
platformDisplay(render)
GlobalSnapshotManager.ensureStarted(composeContext)

displaySignal?.complete(Unit)
hasFrameWaiters = false
launch(composeContext) {
while (true) {
clock.sendFrame(0L) // Frame time value is not used by Compose runtime.
delay(50L)
}
}

val recomposer = Recomposer(composeContext)
val composition = Composition(applier, recomposer)

// Start undispatched to ensure we can use suspending things inside the content.
launch(start = UNDISPATCHED, context = composeContext) {
launch(composeContext, start = CoroutineStart.UNDISPATCHED) {
recomposer.runRecomposeAndApplyChanges()
}

launch(context = composeContext) {
while (true) {
clock.sendFrame(0L) // Frame time value is not used by Compose runtime.
delay(50)
}
}

val terminalInfo = mutableStateOf(
Terminal(
size = IntSize(terminal.info.width, terminal.info.height),
),
)

launch(context = composeContext) {
launch(composeContext) {
while (true) {
val currentTerminalInfo = terminalInfo.value
if (terminal.info.updateTerminalSize() &&
Expand All @@ -117,59 +99,32 @@ public suspend fun runMosaic(body: suspend MosaicScope.() -> Unit): Unit = corou
size = IntSize(terminal.info.width, terminal.info.height),
)
}
delay(50)
delay(50L)
}
}

coroutineScope {
val scope = object : MosaicScope, CoroutineScope by this {
override fun setContent(content: @Composable () -> Unit) {
composition.setContent {
CompositionLocalProvider(LocalTerminal provides terminalInfo.value) {
content()
}
}
}
}
val rootNode = createRootNode()
val applier = MosaicNodeApplier(rootNode) {
val render = rendering.render(rootNode)
platformDisplay(render)
}

var snapshotNotificationsPending = false
val observer: (state: Any) -> Unit = {
if (!snapshotNotificationsPending) {
snapshotNotificationsPending = true
launch {
snapshotNotificationsPending = false
Snapshot.sendApplyNotifications()
}
}
}
val snapshotObserverHandle = Snapshot.registerGlobalWriteObserver(observer)
try {
scope.body()
} finally {
snapshotObserverHandle.dispose()
Composition(applier, recomposer).setContent {
CompositionLocalProvider(LocalTerminal provides terminalInfo.value) {
content()
}
}

// Ensure the final state modification is discovered. We need to ensure that the coroutine
// which is running the recomposition loop wakes up, notices the changes, and waits for the
// next frame. If you are using snapshots this only requires a single yield. If you are not
// then it requires two yields. THIS IS NOT GREAT! But at least it's implementation detail...
// TODO https://issuetracker.google.com/issues/169425431
yield()
yield()
Snapshot.sendApplyNotifications()
yield()
yield()

if (hasFrameWaiters) {
CompletableDeferred<Unit>().also {
displaySignal = it
it.await()
}
val effectJob = checkNotNull(recomposer.effectCoroutineContext[Job]) {
"No Job in effectCoroutineContext of recomposer"
}
effectJob.children.forEach { it.join() }
recomposer.awaitIdle()

recomposer.close()
recomposer.join()

job.cancel()
composition.dispose()
}

internal fun createRootNode(): MosaicNode {
Expand Down Expand Up @@ -206,3 +161,25 @@ internal class MosaicNodeApplier(

override fun onClear() {}
}

internal object GlobalSnapshotManager {
private val started = AtomicBoolean(false)
private val sent = AtomicBoolean(false)

fun ensureStarted(coroutineContext: CoroutineContext) {
if (started.compareAndSet(expect = false, update = true)) {
val channel = Channel<Unit>(1)
CoroutineScope(coroutineContext).launch {
channel.consumeEach {
sent.set(false)
Snapshot.sendApplyNotifications()
}
}
Snapshot.registerGlobalWriteObserver {
if (sent.compareAndSet(expect = false, update = true)) {
channel.trySend(Unit)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
package com.jakewharton.mosaic

internal expect fun platformDisplay(chars: CharSequence)

internal expect class AtomicBoolean(initialValue: Boolean) {

fun set(value: Boolean)

fun compareAndSet(expect: Boolean, update: Boolean): Boolean
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.jakewharton.mosaic

import androidx.compose.runtime.Composable
import kotlinx.coroutines.runBlocking

public fun runMosaicBlocking(body: suspend MosaicScope.() -> Unit) {
public fun runMosaicBlocking(content: @Composable () -> Unit) {
runBlocking {
runMosaic(body)
runMosaic(content)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.jakewharton.mosaic

import java.nio.CharBuffer
import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.atomic.AtomicBoolean as JavaAtomicBoolean
import org.fusesource.jansi.AnsiConsole

private val out = AnsiConsole.out()!!.also { AnsiConsole.systemInstall() }
Expand All @@ -20,3 +21,15 @@ internal actual fun platformDisplay(chars: CharSequence) {
// buffered and not processed until the next frame, or not at all on the final frame.
out.flush()
}

internal actual class AtomicBoolean actual constructor(initialValue: Boolean) {
private val delegate = JavaAtomicBoolean(initialValue)

actual fun set(value: Boolean) {
delegate.set(value)
}

actual fun compareAndSet(expect: Boolean, update: Boolean): Boolean {
return delegate.compareAndSet(expect, update)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,17 @@ package com.jakewharton.mosaic
internal actual fun platformDisplay(chars: CharSequence) {
print(chars.toString())
}

internal actual class AtomicBoolean actual constructor(initialValue: Boolean) {
private var value: Boolean = initialValue

actual fun set(value: Boolean) {
this.value = value
}

actual fun compareAndSet(expect: Boolean, update: Boolean): Boolean {
if (value != expect) return false
value = update
return true
}
}
22 changes: 12 additions & 10 deletions samples/counter/src/commonMain/kotlin/example/counter.kt
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
package example

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.jakewharton.mosaic.MosaicScope
import com.jakewharton.mosaic.ui.Text
import kotlinx.coroutines.delay

suspend fun MosaicScope.runCounter() {
// TODO https://github.com/JakeWharton/mosaic/issues/3
var count by mutableIntStateOf(0)
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }

setContent {
Text("The count is: $count")
}
Text("The count is: $count")

for (i in 1..20) {
delay(250)
count = i
LaunchedEffect(Unit) {
for (i in 1..20) {
delay(250)
count = i
}
}
}
2 changes: 1 addition & 1 deletion samples/counter/src/jsMain/kotlin/example/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ package example
import com.jakewharton.mosaic.runMosaic

suspend fun main() = runMosaic {
runCounter()
Counter()
}
2 changes: 1 addition & 1 deletion samples/counter/src/jvmMain/kotlin/example/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ package example
import com.jakewharton.mosaic.runMosaic

suspend fun main() = runMosaic {
runCounter()
Counter()
}
2 changes: 1 addition & 1 deletion samples/counter/src/nativeMain/kotlin/example/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ package example
import com.jakewharton.mosaic.runMosaicBlocking

fun main() = runMosaicBlocking {
runCounter()
Counter()
}
Loading
Loading