Desktop: make Recomposer local for each window

Closes https://github.com/JetBrains/compose-jb/issues/137

- Make Recomposer local, so each window can have own frame clock (windows can be on different displays with different refresh rate).
  Each window also has own CoroutineScope in which Recomposer and LaunchedEffect's are running.
  Recomposer usually align recomposition with frames. So recomposition will be triggered only with frameClock.sendFrame

- Don't read refresh rate of display in DefaultMonotonicFrameClock (it is very slow on Linux), fix delay to 16ms

- DesktopComposeTestRule
  - don't schedule callback in runOnUiThread if we already on UI thread (same as in AndroidComposeTestRule)
  - Use Density(1f, 1f) by default in all tests
  - waitIdle/awaitIdle now are awaiting for scheduled frame rendering

Change-Id: I680cd56c3dac7b8d68774b299239a6e780ba1a42
Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
diff --git a/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ScrollbarTest.kt b/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ScrollbarTest.kt
index 01dd0b6..6e7136c 100644
--- a/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ScrollbarTest.kt
+++ b/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ScrollbarTest.kt
@@ -54,7 +54,6 @@
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.yield
 import org.jetbrains.skija.Surface
 import org.junit.Assert.assertEquals
 import org.junit.Ignore
@@ -82,7 +81,7 @@
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 25f), end = Offset(0f, 50f))
             }
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-50.dp)
         }
     }
@@ -98,13 +97,13 @@
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 25f), end = Offset(0f, 500f))
             }
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-100.dp)
 
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 99f), end = Offset(0f, -500f))
             }
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(0.dp)
         }
     }
@@ -120,7 +119,7 @@
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(10f, 25f), end = Offset(0f, 50f))
             }
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(0.dp)
         }
     }
@@ -136,7 +135,7 @@
             rule.awaitIdle()
 
             rule.performMouseScroll(0, 25, 1f)
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-10.dp)
         }
     }
@@ -152,7 +151,7 @@
             rule.awaitIdle()
 
             rule.performMouseScroll(0, 99, 1f)
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-10.dp)
         }
     }
@@ -178,13 +177,13 @@
             rule.awaitIdle()
 
             rule.performMouseScroll(20, 25, 10f)
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-100.dp)
 
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 99f), end = Offset(0f, -500f))
             }
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(0.dp)
         }
     }
@@ -202,7 +201,7 @@
             }
 
             tryUntilSucceeded {
-                onFrame()
+                rule.awaitIdle()
                 rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-100.dp)
             }
         }
@@ -221,7 +220,7 @@
             }
 
             tryUntilSucceeded {
-                onFrame()
+                rule.awaitIdle()
                 rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-300.dp)
             }
         }
@@ -243,15 +242,15 @@
                     }
                 }
             }
-            onFrame()
+            rule.awaitIdle()
 
             isContentVisible.value = true
-            onFrame()
+            rule.awaitIdle()
 
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 25f), end = Offset(0f, 500f))
             }
-            onFrame()
+            rule.awaitIdle()
             rule.onNodeWithTag("box0").assertTopPositionInRootIsEqualTo(-100.dp)
         }
     }
@@ -278,7 +277,7 @@
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 0f), end = Offset(0f, 11f), durationMillis = 1)
             }
-            onFrame()
+            rule.awaitIdle()
             assertEquals(2, state.firstVisibleItemIndex)
             assertEquals(4, state.firstVisibleItemScrollOffset)
         }
@@ -306,7 +305,7 @@
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 0f), end = Offset(0f, 26f), durationMillis = 1)
             }
-            onFrame()
+            rule.awaitIdle()
             assertEquals(5, state.firstVisibleItemIndex)
             assertEquals(4, state.firstVisibleItemScrollOffset)
         }
@@ -334,14 +333,14 @@
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 0f), end = Offset(0f, 10000f), durationMillis = 1)
             }
-            onFrame()
+            rule.awaitIdle()
             assertEquals(15, state.firstVisibleItemIndex)
             assertEquals(0, state.firstVisibleItemScrollOffset)
 
             rule.onNodeWithTag("scrollbar").performGesture {
                 swipe(start = Offset(0f, 99f), end = Offset(0f, -10000f), durationMillis = 1)
             }
-            onFrame()
+            rule.awaitIdle()
             assertEquals(0, state.firstVisibleItemIndex)
             assertEquals(0, state.firstVisibleItemScrollOffset)
         }
@@ -358,16 +357,8 @@
         }
     }
 
-    // TODO(demin): move to DesktopComposeTestRule?
-    private suspend fun onFrame() {
-        // TODO(demin): probably we don't need `yield` after we fix https://github.com/JetBrains/compose-jb/issues/137
-        yield()
-        (rule as DesktopComposeTestRule).owners?.onFrame(canvas, 100, 100, 0)
-        rule.awaitIdle()
-    }
-
     private fun ComposeTestRule.performMouseScroll(x: Int, y: Int, delta: Float) {
-        (this as DesktopComposeTestRule).owners!!.onMouseScroll(
+        (this as DesktopComposeTestRule).owners.onMouseScroll(
             x, y, MouseScrollEvent(MouseScrollUnit.Line(delta), Orientation.Vertical)
         )
     }
diff --git a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.kt b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.kt
index 6da8ac9b..9a4ddf3 100644
--- a/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.kt
+++ b/compose/runtime/runtime/src/desktopMain/kotlin/androidx/compose/runtime/ActualDesktop.kt
@@ -17,9 +17,6 @@
 package androidx.compose.runtime
 
 import kotlinx.coroutines.delay
-import kotlinx.coroutines.yield
-import java.awt.DisplayMode
-import java.awt.GraphicsEnvironment
 
 internal actual object Trace {
     actual fun beginSection(name: String): Any? {
@@ -93,29 +90,42 @@
 actual annotation class MainThread()
 actual annotation class CheckResult(actual val suggest: String)
 
-// TODO implement local Recomposer in each Window, so each Window can have own MonotonicFrameClock.
-//  It is needed for smooth animations and for the case when user have multiple windows on multiple
-//  monitors with different refresh rates.
-//  see https://github.com/JetBrains/compose-jb/issues/137
+/**
+ * Clock with fixed delay between frames (16ms), independent from any display/window.
+ *
+ * It is used by [withFrameNanos] and [withFrameMillis] if one is not present
+ * in the calling [kotlin.coroutines.CoroutineContext].
+ *
+ * Use it only where you don't need to show animation in a window.
+ *
+ * If you need a frame clock for changing the state of an animation that should be displayed to
+ * user, use [MonotonicFrameClock] that is bound to the current window. You can access it using
+ * [LaunchedEffect]:
+ * ```
+ * LaunchedEffect {
+ *   val frameClock = coroutineContext[MonotonicFrameClock]
+ * }
+ * ```
+ *
+ * Or using [rememberCoroutineScope]:
+ * ```
+ * val scope = rememberCoroutineScope()
+ * val frameClock = scope.coroutineContext[MonotonicFrameClock]
+ * ```
+ *
+ * If [withFrameNanos] / [withFrameMillis] runs inside the coroutine scope
+ * obtained using [LaunchedEffect] or [rememberCoroutineScope] they also use
+ * [MonotonicFrameClock] which is bound to the current window.
+ */
 actual val DefaultMonotonicFrameClock: MonotonicFrameClock by lazy {
     object : MonotonicFrameClock {
+        private val fps = 60
+
         override suspend fun <R> withFrameNanos(
             onFrame: (Long) -> R
         ): R {
-            if (GraphicsEnvironment.isHeadless()) {
-                yield()
-            } else {
-                delay(1000L / getFramesPerSecond())
-            }
+            delay(1000L / fps)
             return onFrame(System.nanoTime())
         }
-
-        private fun getFramesPerSecond(): Int {
-            val refreshRate = GraphicsEnvironment
-                .getLocalGraphicsEnvironment()
-                .screenDevices.maxOfOrNull { it.displayMode.refreshRate }
-                ?: DisplayMode.REFRESH_RATE_UNKNOWN
-            return if (refreshRate != DisplayMode.REFRESH_RATE_UNKNOWN) refreshRate else 60
-        }
     }
-}
+}
\ No newline at end of file
diff --git a/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopComposeTestRule.kt b/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopComposeTestRule.kt
index bf5d4bf..86fbf16 100644
--- a/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopComposeTestRule.kt
+++ b/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopComposeTestRule.kt
@@ -18,8 +18,9 @@
 
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.ExperimentalComposeApi
-import androidx.compose.runtime.Recomposer
 import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.node.RootForTest
 import androidx.compose.ui.platform.DesktopOwner
 import androidx.compose.ui.platform.DesktopOwners
@@ -38,8 +39,13 @@
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntSize
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.swing.Swing
 import org.jetbrains.skija.Surface
+import org.jetbrains.skiko.FrameDispatcher
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
 import java.util.LinkedList
@@ -57,17 +63,24 @@
         var current: DesktopComposeTestRule? = null
     }
 
-    var owners: DesktopOwners? = null
+    private var window: TestWindow? = null
+    val owners: DesktopOwners get() = window!!.owners
     private var owner: DesktopOwner? = null
 
     override val density: Density
-        get() = TODO()
+        get() = Density(1f, 1f)
 
     override val mainClock: MainTestClock
         get() = TODO()
 
     internal val testDisplaySize: IntSize get() = IntSize(1024, 768)
 
+    private val surface = Surface.makeRasterN32Premul(
+        testDisplaySize.width,
+        testDisplaySize.height
+    )
+    private val canvas = surface.canvas
+
     val executionQueue = LinkedList<() -> Unit>()
 
     private val testOwner = DesktopTestOwner(this)
@@ -77,11 +90,22 @@
         current = this
         return object : Statement() {
             override fun evaluate() {
-                base.evaluate()
-                runExecutionQueue()
+                canvas.clear(Color.Transparent.toArgb())
+
                 runOnUiThread {
-                    owner?.dispose()
-                    owner = null
+                    window = TestWindow()
+                }
+
+                try {
+                    base.evaluate()
+                    runExecutionQueue()
+                } finally {
+                    runOnUiThread {
+                        owner?.dispose()
+                        owner = null
+                        window?.dispose()
+                        window = null
+                    }
                 }
             }
         }
@@ -96,7 +120,7 @@
     @OptIn(ExperimentalComposeApi::class)
     private fun isIdle() =
         !Snapshot.current.hasPendingChanges() &&
-            !Recomposer.runningRecomposers.value.any { it.hasPendingWork }
+            !owners.hasInvalidations()
 
     override fun waitForIdle() {
         while (!isIdle()) {
@@ -114,12 +138,16 @@
     }
 
     override fun <T> runOnUiThread(action: () -> T): T {
-        val task: FutureTask<T> = FutureTask(action)
-        invokeAndWait(task)
-        try {
-            return task.get()
-        } catch (e: ExecutionException) { // Expose the original exception
-            throw e.cause!!
+        return if (isEventDispatchThread()) {
+            action()
+        } else {
+            val task: FutureTask<T> = FutureTask(action)
+            invokeAndWait(task)
+            try {
+                return task.get()
+            } catch (e: ExecutionException) { // Expose the original exception
+                throw e.cause!!
+            }
         }
     }
 
@@ -164,12 +192,7 @@
     }
 
     private fun performSetContent(composable: @Composable() () -> Unit) {
-        val surface = Surface.makeRasterN32Premul(testDisplaySize.width, testDisplaySize.height)
-        val canvas = surface.canvas
-        val owners = DesktopOwners(invalidate = {}).also {
-            owners = it
-        }
-        val owner = DesktopOwner(owners)
+        val owner = DesktopOwner(owners, density)
         owner.setContent(content = composable)
         owner.setSize(testDisplaySize.width, testDisplaySize.height)
         owner.measureAndLayout()
@@ -191,6 +214,27 @@
         return SemanticsNodeInteractionCollection(testContext, useUnmergedTree, matcher)
     }
 
+    private inner class TestWindow {
+        private val coroutineScope = CoroutineScope(Dispatchers.Swing)
+
+        private val frameDispatcher = FrameDispatcher(
+            onFrame = {
+                val nanoTime = System.nanoTime() // TODO(demin): use mainClock?
+                owners.onFrame(canvas, testDisplaySize.width, testDisplaySize.height, nanoTime)
+            },
+            context = coroutineScope.coroutineContext
+        )
+
+        val owners: DesktopOwners = DesktopOwners(
+            coroutineScope = coroutineScope,
+            invalidate = frameDispatcher::scheduleFrame
+        )
+
+        fun dispose() {
+            coroutineScope.cancel()
+        }
+    }
+
     private class DesktopTestOwner(val rule: DesktopComposeTestRule) : TestOwner {
         override fun sendTextInputCommand(node: SemanticsNode, command: List<EditCommand>) {
             TODO()
@@ -205,7 +249,7 @@
         }
 
         override fun getRoots(): Set<RootForTest> {
-            return rule.owners!!.list
+            return rule.owners.list
         }
 
         override val mainClock: MainTestClock
diff --git a/compose/ui/ui-test/src/desktopMain/kotlin/androidx/compose/ui/test/TestComposeWindow.kt b/compose/ui/ui-test/src/desktopMain/kotlin/androidx/compose/ui/test/TestComposeWindow.kt
index 3c47455..b5a2375 100644
--- a/compose/ui/ui-test/src/desktopMain/kotlin/androidx/compose/ui/test/TestComposeWindow.kt
+++ b/compose/ui/ui-test/src/desktopMain/kotlin/androidx/compose/ui/test/TestComposeWindow.kt
@@ -24,6 +24,9 @@
 import androidx.compose.ui.platform.DesktopPlatformAmbient
 import androidx.compose.ui.platform.setContent
 import androidx.compose.ui.unit.Density
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.swing.Swing
 import org.jetbrains.skija.Surface
 
 class TestComposeWindow(
@@ -34,7 +37,9 @@
 ) {
     val surface = Surface.makeRasterN32Premul(width, height)
     val canvas = surface.canvas
-    val owners = DesktopOwners(invalidate = {})
+    val owners = DesktopOwners(
+        coroutineScope = CoroutineScope(Dispatchers.Swing)
+    )
 
     fun setContent(content: @Composable () -> Unit): DesktopOwners {
         val owner = DesktopOwner(owners, density)
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/desktop/ComposeLayer.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/desktop/ComposeLayer.kt
index 43f7cdc..f517028 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/desktop/ComposeLayer.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/desktop/ComposeLayer.kt
@@ -27,6 +27,10 @@
 import androidx.compose.ui.platform.DesktopOwners
 import androidx.compose.ui.platform.setContent
 import androidx.compose.ui.unit.Density
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.swing.Swing
 import org.jetbrains.skija.Canvas
 import org.jetbrains.skiko.HardwareLayer
 import org.jetbrains.skiko.SkiaLayer
@@ -46,10 +50,14 @@
 internal class ComposeLayer {
     private var isDisposed = false
 
+    private val coroutineScope = CoroutineScope(Dispatchers.Swing)
+    // TODO(demin): maybe pass CoroutineScope into AWTDebounceEventQueue and get rid of [cancel]
+    //  method?
     private val events = AWTDebounceEventQueue()
 
     internal val wrapped = Wrapped()
     internal val owners: DesktopOwners = DesktopOwners(
+        coroutineScope,
         wrapped,
         wrapped::needRedraw
     )
@@ -223,6 +231,7 @@
         composition?.dispose()
         owner?.dispose()
         events.cancel()
+        coroutineScope.cancel()
         wrapped.dispose()
         isDisposed = true
     }
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopAnimationClock.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopAnimationClock.kt
deleted file mode 100644
index 6b5b470..0000000
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopAnimationClock.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.compose.ui.platform
-
-import androidx.compose.animation.core.AnimationClockObservable
-import androidx.compose.animation.core.AnimationClockObserver
-import androidx.compose.animation.core.ManualAnimationClock
-
-internal class DesktopAnimationClock(
-    private val invalidate: () -> Unit
-) : AnimationClockObservable {
-    private val manual = ManualAnimationClock(0, dispatchOnSubscribe = false)
-
-    val hasObservers get() = manual.hasObservers
-
-    fun onFrame(nanoTime: Long) {
-        manual.clockTimeMillis = nanoTime / 1_000_000L
-    }
-
-    override fun subscribe(observer: AnimationClockObserver) {
-        manual.subscribe(observer)
-        invalidate()
-    }
-
-    override fun unsubscribe(observer: AnimationClockObserver) {
-        manual.unsubscribe(observer)
-    }
-}
\ No newline at end of file
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwner.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwner.kt
index cbeccb4..c19120d 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwner.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwner.kt
@@ -19,12 +19,12 @@
 package androidx.compose.ui.platform
 
 import androidx.compose.runtime.ExperimentalComposeApi
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.autofill.Autofill
 import androidx.compose.ui.autofill.AutofillTree
-import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.focus.FocusDirection
 import androidx.compose.ui.focus.FocusManager
 import androidx.compose.ui.focus.FocusManagerImpl
@@ -50,11 +50,11 @@
 import androidx.compose.ui.input.key.type
 import androidx.compose.ui.input.mouse.MouseScrollEvent
 import androidx.compose.ui.input.mouse.MouseScrollEventFilter
-import androidx.compose.ui.input.pointer.TestPointerInputEventData
 import androidx.compose.ui.input.pointer.PointerInputEvent
 import androidx.compose.ui.input.pointer.PointerInputEventProcessor
 import androidx.compose.ui.input.pointer.PointerInputFilter
 import androidx.compose.ui.input.pointer.PointerMoveEventFilter
+import androidx.compose.ui.input.pointer.TestPointerInputEventData
 import androidx.compose.ui.layout.RootMeasureBlocks
 import androidx.compose.ui.layout.globalBounds
 import androidx.compose.ui.node.InternalCoreApi
@@ -212,7 +212,7 @@
         drawBlock: (Canvas) -> Unit,
         invalidateParentLayer: () -> Unit
     ) = SkijaLayer(
-        this,
+        this::density,
         invalidateParentLayer = {
             invalidateParentLayer()
             container.invalidate()
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwners.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwners.kt
index 03c0351..15fe89b 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwners.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/DesktopOwners.kt
@@ -15,34 +15,46 @@
  */
 package androidx.compose.ui.platform
 
+import androidx.compose.animation.core.MonotonicFrameAnimationClock
+import androidx.compose.runtime.BroadcastFrameClock
 import androidx.compose.runtime.Recomposer
+import androidx.compose.runtime.snapshots.Snapshot
 import androidx.compose.runtime.staticAmbientOf
 import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent
 import androidx.compose.ui.input.mouse.MouseScrollEvent
 import androidx.compose.ui.input.pointer.PointerId
 import androidx.compose.ui.input.pointer.PointerInputEvent
 import androidx.compose.ui.input.pointer.PointerInputEventData
 import androidx.compose.ui.input.pointer.PointerType
-import androidx.compose.ui.node.InternalCoreApi
-import kotlinx.coroutines.yield
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
 import org.jetbrains.skija.Canvas
 import java.awt.event.InputMethodEvent
 import java.awt.event.KeyEvent
 import java.awt.event.MouseEvent
+import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent
 
-val DesktopOwnersAmbient = staticAmbientOf<DesktopOwners>()
+internal val DesktopOwnersAmbient = staticAmbientOf<DesktopOwners>()
 
-@OptIn(InternalCoreApi::class)
+@OptIn(ExperimentalCoroutinesApi::class)
 class DesktopOwners(
+    coroutineScope: CoroutineScope,
     component: DesktopComponent = DummyDesktopComponent,
-    invalidate: () -> Unit
+    invalidate: () -> Unit = {},
 ) {
     private val _invalidate = invalidate
+    @Volatile
+    private var hasPendingDraws = false
+
+    private var invalidateScheduled = false
     private var willRenderInThisFrame = false
 
     fun invalidate() {
         if (!willRenderInThisFrame) {
+            invalidateScheduled = true
+            hasPendingDraws = true
             _invalidate()
         }
     }
@@ -53,9 +65,32 @@
     private var pointerId = 0L
     private var isMousePressed = false
 
-    internal val animationClock = DesktopAnimationClock(::invalidate)
+    private val dispatcher = FlushCoroutineDispatcher(coroutineScope)
+    private val frameClock = BroadcastFrameClock(::invalidate)
+    private val coroutineContext = dispatcher + frameClock
+
+    internal val animationClock = MonotonicFrameAnimationClock(
+        CoroutineScope(coroutineScope.coroutineContext + coroutineContext)
+    )
+    internal val recomposer = Recomposer(coroutineContext)
     internal val platformInputService: DesktopPlatformInput = DesktopPlatformInput(component)
 
+    init {
+        // TODO(demin): Experimental API (CoroutineStart.UNDISPATCHED).
+        //  Decide what to do before release (copy paste or use different approach).
+        coroutineScope.launch(coroutineContext, start = CoroutineStart.UNDISPATCHED) {
+            recomposer.runRecomposeAndApplyChanges()
+        }
+    }
+
+    /**
+     * Returns true if there are pending recompositions, draws or dispatched tasks.
+     * Can be called from any thread.
+     */
+    fun hasInvalidations() = hasPendingDraws ||
+        recomposer.hasPendingWork ||
+        dispatcher.hasTasks()
+
     fun register(desktopOwner: DesktopOwner) {
         list.add(desktopOwner)
         invalidate()
@@ -66,17 +101,15 @@
         invalidate()
     }
 
-    suspend fun onFrame(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
+    fun onFrame(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
+        invalidateScheduled = false
         willRenderInThisFrame = true
 
         try {
-            animationClock.onFrame(nanoTime)
-
-            // We have to wait recomposition if we want to draw actual animation state
-            // (state can be changed in animationClock.onFrame).
-            // Otherwise there may be a situation when we draw multiple frames with the same
-            // animation state (for example, when FPS always below FPS limit).
-            awaitRecompose()
+            // We must see the actual state before we will render the frame
+            Snapshot.sendApplyNotifications()
+            dispatcher.flush()
+            frameClock.sendFrame(nanoTime)
 
             for (owner in list) {
                 owner.setSize(width, height)
@@ -90,20 +123,12 @@
             owner.draw(canvas)
         }
 
-        if (animationClock.hasObservers) {
+        if (frameClock.hasAwaiters) {
             _invalidate()
         }
-    }
 
-    private suspend fun awaitRecompose() {
-        // We should wait next dispatcher frame because Recomposer doesn't have
-        // pending changes yet, it will only schedule Recomposer.scheduleRecompose in
-        // FrameManager.schedule
-        yield()
-
-        // we can't stuck in infinite loop (because of double dispatching in FrameManager.schedule)
-        while (Recomposer.runningRecomposers.value.any { it.hasPendingWork }) {
-            yield()
+        if (!invalidateScheduled) {
+            hasPendingDraws = false
         }
     }
 
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.kt
new file mode 100644
index 0000000..50ab18f65
--- /dev/null
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/FlushCoroutineDispatcher.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.platform
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Runnable
+import kotlinx.coroutines.launch
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Dispatcher with the ability to immediately perform (flush) all pending tasks.
+ * Without a flush all tasks are dispatched in the dispatcher provided by [scope]
+ */
+internal class FlushCoroutineDispatcher(
+    private val scope: CoroutineScope
+) : CoroutineDispatcher() {
+    private val tasks = ArrayList<Runnable>()
+    private val tasksCopy = ArrayList<Runnable>()
+
+    override fun dispatch(context: CoroutineContext, block: Runnable) {
+        synchronized(tasks) {
+            val isFlushScheduled = tasks.isNotEmpty()
+            tasks.add(block)
+            if (!isFlushScheduled) {
+                scope.launch { flush() }
+            }
+        }
+    }
+
+    fun hasTasks() = synchronized(tasks) {
+        tasks.isNotEmpty()
+    }
+
+    fun flush() {
+        synchronized(tasks) {
+            tasksCopy.clear()
+            tasksCopy.addAll(tasks)
+            tasks.clear()
+        }
+
+        tasksCopy.forEach(Runnable::run)
+    }
+}
\ No newline at end of file
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/SkijaLayer.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/SkijaLayer.kt
index 5aa7f3d..c1fdf58 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/SkijaLayer.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/SkijaLayer.kt
@@ -33,6 +33,7 @@
 import androidx.compose.ui.graphics.toSkijaRect
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.node.OwnedLayer
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.dp
@@ -44,14 +45,14 @@
 import org.jetbrains.skija.ShadowUtils
 
 class SkijaLayer(
-    private val owner: DesktopOwner,
+    private val getDensity: () -> Density,
     private val invalidateParentLayer: () -> Unit,
     private val drawBlock: (Canvas) -> Unit
 ) : OwnedLayer {
     private var size = IntSize.Zero
     private var position = IntOffset.Zero
     private var outlineCache =
-        OutlineCache(owner.density, size, RectangleShape, LayoutDirection.Ltr)
+        OutlineCache(getDensity(), size, RectangleShape, LayoutDirection.Ltr)
     private val matrix = Matrix()
     private val pictureRecorder = PictureRecorder()
     private var picture: Picture? = null
@@ -163,7 +164,7 @@
     }
 
     override fun drawLayer(canvas: Canvas) {
-        outlineCache.density = owner.density
+        outlineCache.density = getDensity()
         if (picture == null) {
             val bounds = size.toBounds().toRect()
             val pictureCanvas = pictureRecorder.beginRecording(bounds.toSkijaRect())
@@ -209,7 +210,7 @@
     override fun updateDisplayList() = Unit
 
     @OptIn(ExperimentalUnsignedTypes::class)
-    fun drawShadow(canvas: DesktopCanvas) = with(owner.density) {
+    fun drawShadow(canvas: DesktopCanvas) = with(getDensity()) {
         val path = when (val outline = outlineCache.outline) {
             is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
             is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/Wrapper.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/Wrapper.kt
index 772faee..330d730 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/Wrapper.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/Wrapper.kt
@@ -18,51 +18,10 @@
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composition
 import androidx.compose.runtime.CompositionReference
-import androidx.compose.runtime.DefaultMonotonicFrameClock
 import androidx.compose.runtime.ExperimentalComposeApi
 import androidx.compose.runtime.Providers
-import androidx.compose.runtime.Recomposer
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.node.LayoutNode
-import kotlin.coroutines.CoroutineContext
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.NonCancellable
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.swing.Swing
-import javax.swing.SwingUtilities
-
-object SwingEmbeddingContext {
-    fun isMainThread(): Boolean {
-        return SwingUtilities.isEventDispatchThread()
-    }
-
-    fun mainThreadCompositionContext(): CoroutineContext {
-        return Dispatchers.Swing + DefaultMonotonicFrameClock
-    }
-}
-
-// TODO: Replace usages with an appropriately scoped implementation
-// Below is a local copy of the old Recomposer.current() implementation.
-@OptIn(ExperimentalCoroutinesApi::class)
-private val GlobalDefaultRecomposer = run {
-    val embeddingContext = SwingEmbeddingContext
-    val mainScope = CoroutineScope(
-        NonCancellable + embeddingContext.mainThreadCompositionContext()
-    )
-
-    Recomposer(mainScope.coroutineContext).also {
-        // NOTE: Launching undispatched so that compositions created with the
-        // singleton instance can assume the recomposer is running
-        // when they perform initial composition. The relevant Recomposer code is
-        // appropriately thread-safe for this.
-        mainScope.launch(start = CoroutineStart.UNDISPATCHED) {
-            it.runRecomposeAndApplyChanges()
-        }
-    }
-}
 
 /**
  * Composes the given composable into [DesktopOwner]
@@ -78,11 +37,7 @@
 ): Composition {
     GlobalSnapshotManager.ensureStarted()
 
-    val composition = Composition(
-        root,
-        DesktopUiApplier(root),
-        parent ?: GlobalDefaultRecomposer
-    )
+    val composition = Composition(root, DesktopUiApplier(root), parent ?: container.recomposer)
     composition.setContent {
         ProvideDesktopAmbients(this) {
             DesktopSelectionContainer(content)
diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/DesktopOwnerTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/DesktopOwnerTest.kt
index 950d856..cb7efda 100644
--- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/DesktopOwnerTest.kt
+++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/DesktopOwnerTest.kt
@@ -32,6 +32,7 @@
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.runtime.ExperimentalComposeApi
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
@@ -47,9 +48,10 @@
 import androidx.compose.ui.layout.Layout
 import androidx.compose.ui.test.junit4.DesktopScreenshotTestRule
 import androidx.compose.ui.unit.dp
+import com.google.common.truth.Truth
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.yield
 import org.junit.Assert.assertFalse
-import org.junit.Ignore
 import org.junit.Rule
 import org.junit.Test
 
@@ -209,7 +211,6 @@
     }
 
     @Test(timeout = 5000)
-    @Ignore("enable after we fix https://github.com/JetBrains/compose-jb/issues/137")
     fun `rendering of transition`() = renderingTest(width = 40, height = 40) {
         var targetValue by mutableStateOf(10f)
 
@@ -221,34 +222,34 @@
             Box(Modifier.size(value.dp).background(Color.Blue))
         }
 
+        // TODO(demin) can we get rid of 'yield' here and write something more meaningful?
+        // animateFloatAsState will remember the initial animation value
+        // only after two asynchronous points:
+        //
+        // 1. LaunchedEffect in animateFloatAsState (wait with 'yield')
+        // 2. startTimeSpecified/withFrameNanos in SuspendAnimation.kt (wait with 'awaitNextRender')
+        //
+        // We need to wait for this moment because later we will change the target value
+        // to animate to it from the initial remembered value.
+        yield()
         awaitNextRender()
         screenshotRule.snap(surface, "frame1_initial")
 
+        targetValue = 40f
+        awaitNextRender()
+        screenshotRule.snap(surface, "frame2_target40_0ms")
+
+        currentTimeMillis = 10
+        awaitNextRender()
+        screenshotRule.snap(surface, "frame3_target40_10ms")
+
         currentTimeMillis = 20
         awaitNextRender()
-        screenshotRule.snap(surface, "frame2_20ms")
+        screenshotRule.snap(surface, "frame4_target40_20ms")
 
         currentTimeMillis = 30
         awaitNextRender()
-        screenshotRule.snap(surface, "frame3_30ms")
-        assertFalse(hasRenders())
-
-        targetValue = 40f
-        currentTimeMillis = 30
-        awaitNextRender()
-        screenshotRule.snap(surface, "frame4_30ms_target40")
-
-        currentTimeMillis = 40
-        awaitNextRender()
-        screenshotRule.snap(surface, "frame5_40ms_target40")
-
-        currentTimeMillis = 50
-        awaitNextRender()
-        screenshotRule.snap(surface, "frame6_50ms_target40")
-
-        currentTimeMillis = 60
-        awaitNextRender()
-        screenshotRule.snap(surface, "frame7_60ms_target40")
+        screenshotRule.snap(surface, "frame5_target40_30ms")
         assertFalse(hasRenders())
     }
 
@@ -328,7 +329,6 @@
     }
 
     @Test(timeout = 5000)
-    @Ignore("enable after we fix https://github.com/JetBrains/compose-jb/issues/137")
     fun `rendering, change state before first onRender`() = renderingTest(
         width = 40,
         height = 40
@@ -343,4 +343,18 @@
         screenshotRule.snap(surface, "frame1_initial")
         assertFalse(hasRenders())
     }
+
+    @Test(timeout = 5000)
+    fun `launch effect`() = renderingTest(width = 40, height = 40) {
+        var effectIsLaunched = false
+
+        setContent {
+            LaunchedEffect(Unit) {
+                effectIsLaunched = true
+            }
+        }
+
+        awaitNextRender()
+        Truth.assertThat(effectIsLaunched).isTrue()
+    }
 }
\ No newline at end of file
diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt
index 7d0b1c1..f374ad0 100644
--- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt
+++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt
@@ -21,46 +21,60 @@
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
 import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.swing.Swing
 import kotlinx.coroutines.yield
 import org.jetbrains.skija.Canvas
 import org.jetbrains.skija.Surface
 import org.jetbrains.skiko.FrameDispatcher
+import kotlin.coroutines.CoroutineContext
 
+@OptIn(ExperimentalCoroutinesApi::class)
 internal fun renderingTest(
     width: Int,
     height: Int,
     platform: DesktopPlatform = DesktopPlatform.Linux,
+    context: CoroutineContext = Dispatchers.Swing,
     block: suspend RenderingTestScope.() -> Unit
-) = runBlocking(Dispatchers.Main) {
-    val scope = RenderingTestScope(width, height, platform)
+) = runBlocking(context) {
+    val scope = RenderingTestScope(width, height, platform, context)
     try {
         scope.block()
     } finally {
-        scope.owner?.dispose()
-        scope.frameDispatcher.cancel()
+        scope.dispose()
     }
 }
 
 internal class RenderingTestScope(
     private val width: Int,
     private val height: Int,
-    private val platform: DesktopPlatform
+    private val platform: DesktopPlatform,
+    coroutineContext: CoroutineContext
 ) {
     var currentTimeMillis = 0L
 
-    val frameDispatcher = FrameDispatcher(Dispatchers.Swing) {
+    private val coroutineScope = CoroutineScope(coroutineContext)
+    private val frameDispatcher = FrameDispatcher(coroutineContext) {
         onRender(currentTimeMillis * 1_000_000)
     }
 
     val surface: Surface = Surface.makeRasterN32Premul(width, height)
     val canvas: Canvas = surface.canvas
     val owners = DesktopOwners(
+        coroutineScope = coroutineScope,
         invalidate = frameDispatcher::scheduleFrame
     )
-    var owner: DesktopOwner? = null
+    private var owner: DesktopOwner? = null
+
+    fun dispose() {
+        owner?.dispose()
+        frameDispatcher.cancel()
+        coroutineScope.cancel()
+    }
 
     private var onRender = CompletableDeferred<Unit>()
 
@@ -75,7 +89,7 @@
         this.owner = owner
     }
 
-    private suspend fun onRender(timeNanos: Long) {
+    private fun onRender(timeNanos: Long) {
         canvas.clear(Color.Transparent.toArgb())
         owners.onFrame(canvas, width, height, timeNanos)
         onRender.complete(Unit)
diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkijaLayerTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkijaLayerTest.kt
index 637863c..c0f032e 100644
--- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkijaLayerTest.kt
+++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/SkijaLayerTest.kt
@@ -22,6 +22,7 @@
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.TransformOrigin
 import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.unit.Density
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.LayoutDirection
@@ -315,7 +316,7 @@
     }
 
     private fun TestSkijaLayer() = SkijaLayer(
-        owner = DesktopOwner(DesktopOwners(invalidate = {})),
+        { Density(1f, 1f) },
         invalidateParentLayer = {},
         drawBlock = {}
     )