[go: nahoru, domu]

Merge "ui-desktop: different redrawing strategy with DesktopUIDispatcher" into androidx-master-dev
diff --git a/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/ActualDesktop.kt b/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/ActualDesktop.kt
index c5ec776..baa6d0f 100644
--- a/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/ActualDesktop.kt
+++ b/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/ActualDesktop.kt
@@ -16,24 +16,6 @@
 
 package androidx.compose.dispatch
 
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.withContext
-
-/**
- * TODO: a more appropriate implementation that matches the vsync rate of the default display.
- * The current implementation will result in clock skew over time as resuming from delay() is not
- * guaranteed to be precise or frame-accurate.
- */
-private object MainDispatcherFrameClock : MonotonicFrameClock {
-    private const val DefaultFrameDelay = 16L // milliseconds
-
-    override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
-        withContext(Dispatchers.Main) {
-            delay(DefaultFrameDelay)
-            onFrame(System.nanoTime())
-        }
+actual val DefaultMonotonicFrameClock: MonotonicFrameClock by lazy {
+    DesktopUiDispatcher.Dispatcher.frameClock
 }
-
-actual val DefaultMonotonicFrameClock: MonotonicFrameClock
-    get() = MainDispatcherFrameClock
diff --git a/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/DesktopUiDispatcher.kt b/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/DesktopUiDispatcher.kt
new file mode 100644
index 0000000..5b7082e
--- /dev/null
+++ b/compose/compose-dispatch/src/desktopMain/kotlin/androidx/compose/dispatch/DesktopUiDispatcher.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.dispatch
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Runnable
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.awt.event.ActionListener
+import javax.swing.SwingUtilities.invokeLater
+import javax.swing.Timer
+import kotlin.coroutines.CoroutineContext
+
+private typealias Action = (Long) -> Unit
+private typealias Queue = ArrayList<Action>
+
+/**
+ * Ticks scheduler for Desktop. It tries to mimic Android's Choreographer and has multiple levels of
+ * callbacks: "just" callbacks and "after" callbacks (Choreographer has more, but two is enough for us)
+ * It's necessary because recomposition and drawing should be synchronized, otherwise races and
+ * inconsistencies are possible. On Android, this synchronization is achieved implicitly by executing
+ * recomposition and scheduling of redrawing as different types of callbacks. It guarantees,
+ * that after recomposition, redrawing happens in the exactly same tick.
+ *
+ * There are some plans to make possible redrawing based on composition snapshots,
+ * so maybe some requirements for dispatcher will be mitigated in the future.
+ **/
+class DesktopUiDispatcher : CoroutineDispatcher() {
+    private val lock = Any()
+    private var callbacks = Queue()
+    private var afterCallbacks = Queue()
+
+    @Volatile
+    private var scheduled = false
+
+    private fun scheduleIfNeeded() {
+        synchronized(lock) {
+            if (!scheduled && (callbacks.isNotEmpty() || afterCallbacks.isNotEmpty())) {
+                invokeLater { tick() }
+                scheduled = true
+            }
+        }
+    }
+
+    val frameClock: MonotonicFrameClock = object :
+        MonotonicFrameClock {
+        override suspend fun <R> withFrameNanos(
+            onFrame: (Long) -> R
+        ): R {
+            return suspendCancellableCoroutine { co ->
+                val callback = { now: Long ->
+                    val res = runCatching {
+                        onFrame(now)
+                    }
+                    co.resumeWith(res)
+                }
+                scheduleCallback(callback)
+                co.invokeOnCancellation {
+                    removeCallback(callback)
+                }
+            }
+        }
+    }
+
+    private fun tick() {
+        scheduled = false
+        val now = System.nanoTime()
+        runCallbacks(now, callbacks)
+        runCallbacks(now, afterCallbacks)
+        scheduleIfNeeded()
+    }
+
+    fun scheduleCallback(action: Action) {
+        synchronized(lock) {
+            callbacks.add(action)
+            scheduleIfNeeded()
+        }
+    }
+
+    fun scheduleAfterCallback(action: Action) {
+        synchronized(lock) {
+            afterCallbacks.add(action)
+        }
+    }
+
+    fun removeCallback(action: (Long) -> Unit) {
+        synchronized(lock) {
+            callbacks.remove(action)
+        }
+    }
+
+    fun scheduleCallbackWithDelay(delay: Int, action: Action) {
+        Timer(delay,
+            ActionListener {
+                scheduleCallback { action(System.nanoTime()) }
+            }).apply {
+            isRepeats = false
+            start()
+        }
+    }
+
+    private fun runCallbacks(now: Long, callbacks: Queue) {
+        synchronized(lock) {
+            callbacks.toList().also { callbacks.clear() }
+        }.forEach { it(now) }
+    }
+
+    override fun dispatch(context: CoroutineContext, block: Runnable) {
+        scheduleCallback { block.run() }
+    }
+
+    companion object {
+        val Dispatcher: DesktopUiDispatcher by lazy { DesktopUiDispatcher() }
+        val Main: CoroutineContext by lazy {
+            Dispatcher + Dispatcher.frameClock
+        }
+    }
+}
\ No newline at end of file
diff --git a/compose/compose-runtime/src/desktopMain/kotlin/androidx/compose/ActualDesktop.kt b/compose/compose-runtime/src/desktopMain/kotlin/androidx/compose/ActualDesktop.kt
index 9a30d380..2afaf2d 100644
--- a/compose/compose-runtime/src/desktopMain/kotlin/androidx/compose/ActualDesktop.kt
+++ b/compose/compose-runtime/src/desktopMain/kotlin/androidx/compose/ActualDesktop.kt
@@ -18,7 +18,7 @@
 
 import javax.swing.SwingUtilities
 import kotlin.coroutines.CoroutineContext
-import kotlinx.coroutines.Dispatchers
+import androidx.compose.dispatch.DesktopUiDispatcher
 
 // API to allow override embedding context creation mechanism for tests.
 var EmbeddingContextFactory: (() -> EmbeddingContext)? = null
@@ -29,19 +29,19 @@
     }
 
     override fun mainThreadCompositionContext(): CoroutineContext {
-        return Dispatchers.Main
+        return DesktopUiDispatcher.Main
     }
 
     override fun postOnMainThread(block: () -> Unit) {
-        SwingUtilities.invokeLater(block)
+        DesktopUiDispatcher.Dispatcher.scheduleCallback { block() }
     }
 
     private val cancelled = mutableSetOf<ChoreographerFrameCallback>()
 
     override fun postFrameCallback(callback: ChoreographerFrameCallback) {
-        postOnMainThread {
+        DesktopUiDispatcher.Dispatcher.scheduleCallback { now ->
             if (callback !in cancelled) {
-                callback.doFrame(System.currentTimeMillis() * 1000000)
+                callback.doFrame(now)
             } else {
                 cancelled.remove(callback)
             }
diff --git a/ui/ui-desktop/android-emu/src/desktopMain/kotlin/android/view/View.kt b/ui/ui-desktop/android-emu/src/desktopMain/kotlin/android/view/View.kt
index d85749e..6a2afee 100644
--- a/ui/ui-desktop/android-emu/src/desktopMain/kotlin/android/view/View.kt
+++ b/ui/ui-desktop/android-emu/src/desktopMain/kotlin/android/view/View.kt
@@ -73,7 +73,9 @@
         return false
     }
 
-    open fun invalidate() {}
+    open fun invalidate() {
+        onInvalidate()
+    }
 
     open fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {}
 
@@ -199,10 +201,21 @@
         }
     }
 
+    open fun offsetLeftAndRight(offset: Int) {
+        if (offset != 0) {
+            left += offset
+            right += offset
+        }
+    }
+
     open fun onAttachedToWindow() {
         isAttachedToWindow = true
         attachListeners.forEach {
             it.onViewAttachedToWindow(this)
         }
     }
+
+    open fun onInvalidate() {
+        if (parent is View) (parent as View).onInvalidate()
+    }
 }
diff --git a/ui/ui-desktop/samples/src/jvmMain/kotlin/androidx/ui/desktop/examples/example1/Main.kt b/ui/ui-desktop/samples/src/jvmMain/kotlin/androidx/ui/desktop/examples/example1/Main.kt
index 0d39c91..66577e8 100644
--- a/ui/ui-desktop/samples/src/jvmMain/kotlin/androidx/ui/desktop/examples/example1/Main.kt
+++ b/ui/ui-desktop/samples/src/jvmMain/kotlin/androidx/ui/desktop/examples/example1/Main.kt
@@ -46,6 +46,8 @@
 import androidx.ui.text.font.fontFamily
 import androidx.ui.desktop.font
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Row
 import androidx.ui.unit.IntSize
 
 private const val title = "Desktop Compose Elements"
@@ -70,6 +72,7 @@
             },
             bodyContent = {
                 val amount = state { 0 }
+                val animation = state { true }
                 val text = state { "Hello" }
                 Column(Modifier.fillMaxSize(), Arrangement.SpaceEvenly) {
                     Text(
@@ -126,7 +129,21 @@
                     }) {
                         Text("Base")
                     }
-                    CircularProgressIndicator()
+
+                    Row(modifier = Modifier.padding(vertical = 10.dp),
+                        verticalGravity = Alignment.CenterVertically) {
+                        Button(
+                            >
+                            animation.value = !animation.value
+                        }) {
+                            Text("Toggle")
+                        }
+
+                        if (animation.value) {
+                            CircularProgressIndicator()
+                        }
+                    }
+
                     Slider(value = amount.value.toFloat() / 100f,
                          amount.value = (it * 100).toInt() })
                     FilledTextField(
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/BaseAnimationClock.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/BaseAnimationClock.kt
new file mode 100644
index 0000000..395044b
--- /dev/null
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/BaseAnimationClock.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.ui.desktop
+
+import androidx.animation.AnimationClockObservable
+import androidx.animation.AnimationClockObserver
+
+/**
+ * Copy-pasted androidx.animation.BaseAnimationClock to make possible to use internal
+ * dispatchTime.
+ * TODO: Move DesktopAnimationClock to ui-animation-core when MPP is ready. Remove this base
+ * class after that
+ */
+abstract class BaseAnimationClock : AnimationClockObservable {
+    // Using LinkedHashSet to increase removal performance
+    private val observers: MutableSet<AnimationClockObserver> = LinkedHashSet()
+
+    private val pendingActions: MutableList<Int> = mutableListOf()
+    private val pendingObservers: MutableList<AnimationClockObserver> = mutableListOf()
+
+    private fun addToPendingActions(action: Int, observer: AnimationClockObserver) =
+        synchronized(pendingActions) {
+            pendingActions.add(action) && pendingObservers.add(observer)
+        }
+
+    private fun pendingActionsIsNotEmpty(): Boolean =
+        synchronized(pendingActions) {
+            pendingActions.isNotEmpty()
+        }
+
+    private inline fun forEachObserver(crossinline action: (AnimationClockObserver) -> Unit) =
+        synchronized(observers) {
+            observers.forEach(action)
+        }
+
+    /**
+     * Subscribes [observer] to this clock. Duplicate subscriptions will be ignored.
+     */
+    override fun subscribe(observer: AnimationClockObserver) {
+        addToPendingActions(AddAction, observer)
+    }
+
+    override fun unsubscribe(observer: AnimationClockObserver) {
+        addToPendingActions(RemoveAction, observer)
+    }
+
+    internal open fun dispatchTime(frameTimeMillis: Long) {
+        processPendingActions()
+
+        forEachObserver {
+            it.onAnimationFrame(frameTimeMillis)
+        }
+
+        while (pendingActionsIsNotEmpty()) {
+            processPendingActions().forEach {
+                it.onAnimationFrame(frameTimeMillis)
+            }
+        }
+    }
+
+    internal fun hasObservers(): Boolean {
+        synchronized(observers) {
+            // Start with processing pending actions: it might remove the last observers
+            processPendingActions()
+            return observers.isNotEmpty()
+        }
+    }
+
+    private fun processPendingActions(): Set<AnimationClockObserver> {
+        synchronized(observers) {
+            synchronized(pendingActions) {
+                if (pendingActions.isEmpty()) {
+                    return emptySet()
+                }
+                val additions = LinkedHashSet<AnimationClockObserver>()
+                pendingActions.forEachIndexed { i, action ->
+                    when (action) {
+                        AddAction -> {
+                            // This check ensures that we only have one instance of the observer in
+                            // the callbacks at any given time.
+                            if (observers.add(pendingObservers[i])) {
+                                additions.add(pendingObservers[i])
+                            }
+                        }
+                        RemoveAction -> {
+                            observers.remove(pendingObservers[i])
+                            additions.remove(pendingObservers[i])
+                        }
+                    }
+                }
+                pendingActions.clear()
+                pendingObservers.clear()
+                return additions
+            }
+        }
+    }
+
+    private companion object {
+        private const val AddAction = 1
+        private const val RemoveAction = 2
+    }
+}
\ No newline at end of file
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/ComposeInit.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/ComposeInit.kt
index 6682a79..590ab06 100644
--- a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/ComposeInit.kt
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/ComposeInit.kt
@@ -18,7 +18,9 @@
 
 package androidx.ui.desktop
 
+import androidx.animation.rootAnimationClockFactory
 import androidx.compose.InternalComposeApi
+import androidx.compose.dispatch.DesktopUiDispatcher
 import androidx.ui.graphics.DesktopCanvas
 import androidx.ui.graphics.DesktopImageShader
 import androidx.ui.graphics.DesktopInternalCanvasHolder
@@ -69,5 +71,11 @@
         GraphicsFactory.Shader.image = ::DesktopImageShader
         paragraphIntrinsicsActualFactory = ::DesktopParagraphIntrinsics
         paragraphActualFactory = ::DesktopParagraph
+        @OptIn(androidx.animation.InternalAnimationApi::class)
+        rootAnimationClockFactory = {
+            // TODO: detect actual display refresh rate? what to do with displays with
+            //  different refresh rates?
+            DesktopAnimationClock(60, DesktopUiDispatcher.Dispatcher)
+        }
     }
 }
\ No newline at end of file
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/DesktopParagraph.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/DesktopParagraph.kt
index 2bb81f1..16b1710 100644
--- a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/DesktopParagraph.kt
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/DesktopParagraph.kt
@@ -20,13 +20,16 @@
 import androidx.ui.graphics.Canvas
 import androidx.ui.graphics.DesktopPath
 import androidx.ui.graphics.Path
+import androidx.ui.graphics.toAndroidX
 import androidx.ui.text.Paragraph
 import androidx.ui.text.ParagraphConstraints
 import androidx.ui.text.ParagraphIntrinsics
 import androidx.ui.text.TextRange
 import androidx.ui.text.style.ResolvedTextDirection
+import org.jetbrains.skija.paragraph.LineMetrics
 import org.jetbrains.skija.paragraph.RectHeightMode
 import org.jetbrains.skija.paragraph.RectWidthMode
+import kotlin.math.floor
 
 internal class DesktopParagraph(
     intrinsics: ParagraphIntrinsics,
@@ -56,10 +59,10 @@
         get() = paragraphIntrinsics.maxIntrinsicWidth
 
     override val firstBaseline: Float
-        get() = para.getLineMetrics().first().baseline.toFloat()
+        get() = para.getLineMetrics().firstOrNull()?.run { baseline.toFloat() } ?: 0f
 
     override val lastBaseline: Float
-        get() = para.getLineMetrics().last().baseline.toFloat()
+        get() = para.getLineMetrics().lastOrNull()?.run { baseline.toFloat() } ?: 0f
 
     override val didExceedMaxLines: Boolean
         // TODO: support text ellipsize.
@@ -68,10 +71,11 @@
     override val lineCount: Int
         get() = para.lineNumber.toInt()
 
-    override val placeholderRects: List<Rect?> get() {
-        println("Paragraph.placeholderRects")
-        return listOf()
-    }
+    override val placeholderRects: List<Rect?>
+        get() {
+            println("Paragraph.placeholderRects")
+            return listOf()
+        }
 
     override fun getPathForRange(start: Int, end: Int): Path {
         val boxes = para.getRectsForRange(
@@ -88,8 +92,16 @@
     }
 
     override fun getCursorRect(offset: Int): Rect {
-        println("Paragraph.getCursorRect $offset")
-        return Rect(0.0f, 0.0f, 0.0f, 0.0f)
+        val cursorWidth = 4.0f
+        val horizontal = getHorizontalPosition(offset, true)
+        val line = getLineForOffset(offset)
+
+        return Rect(
+            horizontal - 0.5f * cursorWidth,
+            getLineTop(line),
+            horizontal + 0.5f * cursorWidth,
+            getLineBottom(line)
+        )
     }
 
     override fun getLineLeft(lineIndex: Int): Float {
@@ -102,35 +114,33 @@
         return 0.0f
     }
 
-    override fun getLineTop(lineIndex: Int): Float {
-        println("Paragraph.getLineTop $lineIndex")
-        return 0.0f
+    override fun getLineTop(lineIndex: Int) =
+        para.lineMetrics.getOrNull(lineIndex)?.let { line ->
+            floor((line.baseline - line.ascent).toFloat())
+        } ?: 0f
+
+    override fun getLineBottom(lineIndex: Int) =
+        para.lineMetrics.getOrNull(lineIndex)?.let { line ->
+            floor((line.baseline + line.descent).toFloat())
+        } ?: 0f
+
+    private fun lineMetricsForOffset(offset: Int): LineMetrics? {
+        val metrics = para.lineMetrics
+        for (line in metrics) {
+            if (offset <= line.endIndex) {
+                return line
+            }
+        }
+        return null
     }
 
-    override fun getLineBottom(lineIndex: Int): Float {
-        println("Paragraph.getLineBottom $lineIndex")
-        return 0.0f
-    }
+    override fun getLineHeight(lineIndex: Int) = para.lineMetrics[lineIndex].height.toFloat()
 
-    override fun getLineHeight(lineIndex: Int): Float {
-        println("Paragraph.getLineHeight $lineIndex")
-        return 0.0f
-    }
+    override fun getLineWidth(lineIndex: Int) = para.lineMetrics[lineIndex].width.toFloat()
 
-    override fun getLineWidth(lineIndex: Int): Float {
-        println("Paragraph.getLineWidth $lineIndex")
-        return 0.0f
-    }
+    override fun getLineStart(lineIndex: Int) = para.lineMetrics[lineIndex].startIndex.toInt()
 
-    override fun getLineStart(lineIndex: Int): Int {
-        println("Paragraph.getLineStart $lineIndex")
-        return 0
-    }
-
-    override fun getLineEnd(lineIndex: Int): Int {
-        println("Paragraph.getLineEnd $lineIndex")
-        return 0
-    }
+    override fun getLineEnd(lineIndex: Int) = para.lineMetrics[lineIndex].endIndex.toInt()
 
     override fun getLineEllipsisOffset(lineIndex: Int): Int {
         println("Paragraph.getLineEllipsisOffset $lineIndex")
@@ -142,14 +152,27 @@
         return 0
     }
 
-    override fun getLineForOffset(offset: Int): Int {
-        println("Paragraph.getLineForOffset $offset")
-        return 0
-    }
+    override fun getLineForOffset(offset: Int) =
+        lineMetricsForOffset(offset)?.run { lineNumber.toInt() }
+            ?: 0
 
     override fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float {
-        println("getHorizontalPosition $offset, $usePrimaryDirection")
-        return 0.0f
+        val metrics = lineMetricsForOffset(offset)
+
+        return when {
+            metrics == null -> 0f
+            metrics.startIndex.toInt() == offset || metrics.startIndex == metrics.endIndex -> 0f
+            metrics.endIndex.toInt() == offset -> {
+                para.getRectsForRange(offset - 1, offset, RectHeightMode.MAX, RectWidthMode.MAX)
+                    .first()
+                    .rect.right
+            }
+            else -> {
+                para.getRectsForRange(
+                    offset, offset + 1, RectHeightMode.MAX, RectWidthMode.MAX
+                ).first().rect.left
+            }
+        }
     }
 
     override fun getParagraphDirection(offset: Int): ResolvedTextDirection =
@@ -162,17 +185,18 @@
         return para.getGlyphPositionAtCoordinate(position.x, position.y).position
     }
 
-    override fun getBoundingBox(offset: Int): Rect {
-        println("getBoundingBox $offset")
-        return Rect(0.0f, 0.0f, 0.0f, 0.0f)
-    }
+    override fun getBoundingBox(offset: Int) =
+        para.getRectsForRange(
+            offset, offset + 1, RectHeightMode.MAX, RectWidthMode
+                .MAX
+        ).first().rect.toAndroidX()
 
     override fun getWordBoundary(offset: Int): TextRange {
-        println("getWordBoundary $offset")
+        println("Paragraph.getWordBoundary $offset")
         return TextRange(0, 0)
     }
 
     override fun paint(canvas: Canvas) {
         para.paint(canvas.nativeCanvas.skijaCanvas, 0.0f, 0.0f)
     }
-}
+}
\ No newline at end of file
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/SkiaWindow.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/SkiaWindow.kt
index 8a795e2..d846a37 100644
--- a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/SkiaWindow.kt
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/SkiaWindow.kt
@@ -15,6 +15,8 @@
  */
 package androidx.ui.desktop
 
+import androidx.animation.AnimationClockObserver
+import androidx.compose.dispatch.DesktopUiDispatcher
 import androidx.ui.text.platform.paragraphActualFactory
 import androidx.ui.text.platform.paragraphIntrinsicsActualFactory
 import com.jogamp.opengl.GL
@@ -23,7 +25,6 @@
 import com.jogamp.opengl.GLEventListener
 import com.jogamp.opengl.GLProfile
 import com.jogamp.opengl.awt.GLCanvas
-import com.jogamp.opengl.util.FPSAnimator
 import org.jetbrains.skija.BackendRenderTarget
 import org.jetbrains.skija.Canvas
 import org.jetbrains.skija.ColorSpace
@@ -85,9 +86,8 @@
 
     override val parent: AppFrame
     override val glCanvas: GLCanvas
-    override var animator: FPSAnimator? = null
     override var renderer: SkiaRenderer? = null
-    override val vsync = false
+    override val vsync = true
 
     constructor(width: Int, height: Int, parent: AppFrame) : super() {
         this.parent = parent
@@ -120,9 +120,8 @@
 
     override val parent: AppFrame
     override val glCanvas: GLCanvas
-    override var animator: FPSAnimator? = null
     override var renderer: SkiaRenderer? = null
-    override val vsync = false
+    override val vsync = true
 
     constructor(
         attached: JFrame?,
@@ -145,22 +144,9 @@
 internal interface SkiaFrame {
     val parent: AppFrame
     val glCanvas: GLCanvas
-    var animator: FPSAnimator?
     var renderer: SkiaRenderer?
     val vsync: Boolean
 
-    fun setFps(fps: Int) {
-        animator?.stop()
-        animator = if (fps > 0) {
-            FPSAnimator(fps).also {
-                it.add(glCanvas)
-                it.start()
-            }
-        } else {
-            null
-        }
-    }
-
     fun close() {
         glCanvas.destroy()
     }
@@ -206,6 +192,21 @@
     }
 }
 
+// Simple FPS tracker for debug purposes
+internal class FPSTracker {
+    private var t0 = 0L
+    private val times = DoubleArray(155)
+    private var timesIdx = 0
+
+    fun track() {
+        val t1 = System.nanoTime()
+        times[timesIdx] = (t1 - t0) / 1000000.0
+        t0 = t1
+        timesIdx = (timesIdx + 1) % times.size
+        println("FPS: ${1000 / times.takeWhile { it > 0 }.average()}")
+    }
+}
+
 private fun initCanvas(frame: SkiaFrame, vsync: Boolean = false): GLCanvas {
     val profile = GLProfile.get(GLProfile.GL3)
     val capabilities = GLCapabilities(profile)
@@ -269,10 +270,9 @@
             AppManager.removeWindow(frame.parent)
         }
 
-        override fun display(drawable: GLAutoDrawable?) {
+        override fun display(drawable: GLAutoDrawable) {
             skijaState.apply {
-                val gl = drawable!!.gl!!
-                // drawable.swapBuffers()
+                val gl = drawable.gl!!
                 canvas!!.clear(0xFFFFFFF)
                 gl.glBindTexture(GL.GL_TEXTURE_2D, textureId)
                 frame.renderer!!.onRender(
@@ -287,4 +287,37 @@
     })
 
     return glCanvas
-}
\ No newline at end of file
+}
+
+internal class DesktopAnimationClock(fps: Int, val dispatcher: DesktopUiDispatcher) :
+    BaseAnimationClock() {
+    val delay = 1_000 / fps
+
+    @Volatile
+    private var scheduled = false
+    private fun frameCallback(time: Long) {
+        scheduled = false
+        dispatchTime(time / 1000000)
+    }
+
+    override fun subscribe(observer: AnimationClockObserver) {
+        super.subscribe(observer)
+        scheduleIfNeeded()
+    }
+
+    override fun dispatchTime(frameTimeMillis: Long) {
+        super.dispatchTime(frameTimeMillis)
+        scheduleIfNeeded()
+    }
+
+    private fun scheduleIfNeeded() {
+        when {
+            scheduled -> return
+            !hasObservers() -> return
+            else -> {
+                scheduled = true
+                dispatcher.scheduleCallbackWithDelay(delay, ::frameCallback)
+            }
+        }
+    }
+}
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/Wrapper.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/Wrapper.kt
index 307aee3..27c225d 100644
--- a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/Wrapper.kt
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/desktop/Wrapper.kt
@@ -17,66 +17,35 @@
 
 import android.content.Context
 import android.view.MotionEvent
-import androidx.animation.ManualAnimationClock
-import androidx.animation.rootAnimationClockFactory
 import androidx.compose.Composable
 import androidx.ui.desktop.view.LayoutScope
 import javax.swing.SwingUtilities
 import org.jetbrains.skija.Canvas
 
-@OptIn(androidx.animation.InternalAnimationApi::class)
 fun Window.setContent(content: @Composable () -> Unit) {
     SwingUtilities.invokeLater {
-        val fps = 60
-        val clocks = mutableListOf<ManualAnimationClock>()
-        rootAnimationClockFactory = {
-            ManualAnimationClock(0L).also {
-                clocks.add(it)
-            }
-        }
-
-        val mainLayout = LayoutScope()
+        val mainLayout = LayoutScope(glCanvas)
         mainLayout.setContent(content)
-
         this.renderer = Renderer(
             mainLayout.context,
-            mainLayout.platformInputService,
-            clocks,
-            fps)
-
-        this.setFps(fps)
+            mainLayout.platformInputService)
     }
 }
 
-@OptIn(androidx.animation.InternalAnimationApi::class)
 fun Dialog.setContent(content: @Composable () -> Unit) {
     SwingUtilities.invokeLater {
-        val fps = 60
-        val clocks = mutableListOf<ManualAnimationClock>()
-        rootAnimationClockFactory = {
-            ManualAnimationClock(0L).also {
-                clocks.add(it)
-            }
-        }
-
-        val mainLayout = LayoutScope()
+        val mainLayout = LayoutScope(glCanvas)
         mainLayout.setContent(content)
 
         this.renderer = Renderer(
             mainLayout.context,
-            mainLayout.platformInputService,
-            clocks,
-            fps)
-
-        this.setFps(fps)
+            mainLayout.platformInputService)
     }
 }
 
 private class Renderer(
     val context: Context,
-    val platformInputService: DesktopPlatformInput,
-    val clocks: List<ManualAnimationClock>,
-    val fps: Int
+    val platformInputService: DesktopPlatformInput
 ) : SkiaRenderer {
 
     private val canvases = mutableMapOf<LayoutScope, Canvas?>()
@@ -107,13 +76,9 @@
 
     override fun onReshape(canvas: Canvas, width: Int, height: Int) {
         clearCanvases()
-        draw(canvas, width, height)
     }
 
     override fun onRender(canvas: Canvas, width: Int, height: Int) {
-        clocks.forEach {
-            it.clockTimeMillis += 1000 / fps
-        }
         draw(canvas, width, height)
     }
 
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/graphics/Rects.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/graphics/Rects.kt
index 461cc99..4e7bbe8 100644
--- a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/graphics/Rects.kt
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/graphics/Rects.kt
@@ -24,4 +24,7 @@
         right,
         bottom
     )
-}
\ No newline at end of file
+}
+
+internal fun org.jetbrains.skija.Rect.toAndroidX() =
+    Rect(left, top, right, bottom)
\ No newline at end of file
diff --git a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/view/LayoutScope.kt b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/view/LayoutScope.kt
index 31a8c1b..39ff31e 100644
--- a/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/view/LayoutScope.kt
+++ b/ui/ui-desktop/src/jvmMain/kotlin/androidx/ui/view/LayoutScope.kt
@@ -22,6 +22,7 @@
 import androidx.compose.Composable
 import androidx.compose.Providers
 import androidx.compose.Recomposer
+import androidx.compose.dispatch.DesktopUiDispatcher
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LifecycleRegistry
@@ -38,6 +39,7 @@
 import androidx.ui.desktop.LayoutScopeGlobal
 import androidx.ui.input.TextInputService
 import androidx.ui.unit.IntOffset
+import com.jogamp.opengl.awt.GLCanvas
 import org.jetbrains.skija.Canvas
 
 class LayoutScope {
@@ -52,9 +54,26 @@
     val width: Int get() = layout.right - layout.left
     val height: Int get() = layout.bottom - layout.top
 
-    constructor() {
+    // Optimization: we don't need more than one redrawing per tick
+    var redrawingScheduled = false
+
+    constructor(glCanvas: GLCanvas) {
         context = object : Context() {}
-        layout = object : ViewGroup(context) {}
+        layout = object : ViewGroup(context) {
+            override fun onInvalidate() {
+                if (!redrawingScheduled) {
+                    DesktopUiDispatcher.Dispatcher.scheduleAfterCallback {
+                        redrawingScheduled = false
+                        if (Recomposer.current().hasPendingChanges()) {
+                            onInvalidate()
+                        } else {
+                            redraw(glCanvas)
+                        }
+                    }
+                    redrawingScheduled = true
+                }
+            }
+        }
     }
 
     constructor(composeView: View, context: Context) {
@@ -66,6 +85,10 @@
     internal lateinit var platformInputService: DesktopPlatformInput
         private set
 
+    private fun redraw(glCanvas: GLCanvas) {
+        glCanvas.display()
+    }
+
     fun setContent(content: @Composable () -> Unit) {
         platformInputService = DesktopPlatformInput()
         ViewTreeLifecycleOwner.set(layout, object : LifecycleOwner {