diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayController.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayController.kt index be10dd673..32ec5d9d6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayController.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayController.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.scene.ComposeScene -import androidx.compose.ui.scene.PlatformLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -33,6 +32,8 @@ import dev.nucleusframework.window.tao.scene.MetalTextureHostCache import dev.nucleusframework.window.tao.scene.TaoComposeSceneContext import dev.nucleusframework.window.tao.scene.TaoMetalTextureHost import dev.nucleusframework.window.tao.scene.TaoRecordedSurface +import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.platformLayersSceneBundle import dev.nucleusframework.window.tao.scene.recordSceneToPicture import org.jetbrains.skia.DirectContext import kotlin.coroutines.CoroutineContext @@ -203,7 +204,8 @@ internal class NativeViewOverlayController( // Created on / used on / closed on the host's render thread (Skia Metal // DirectContext is thread-affine). See TaoComposeSceneHost's render thread. private var directContext: DirectContext? = null - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene /** * Set in [dispose] before GPU teardown; read on the render thread via @@ -404,28 +406,28 @@ internal class NativeViewOverlayController( // bounds, intercept clicks themselves (instead of falling // through to the user's native subview), and dismiss on // outside-click via the panel's NSEvent local monitor. - scene = - PlatformLayersComposeScene( + sceneBundle = + platformLayersSceneBundle( + coroutineContext = popupHost.sceneCoroutineContext, density = Density(scale), layoutDirection = LayoutDirection.Ltr, size = IntSize(widthPx, heightPx), - coroutineContext = popupHost.sceneCoroutineContext, composeSceneContext = TaoComposeSceneContext( platformContext = ourPlatformContext, - ) { density, layoutDirection, focusable, cc -> + ) { density, layoutDirection, focusable, consumeOutside -> TaoPopupSceneLayer( host = overlayPopupHost, initialDensity = density, initialLayoutDirection = layoutDirection, initialFocusable = focusable, - parentCompositionContext = cc, + initialConsumePointerInputOutside = consumeOutside, ) }, - invalidate = { popupHost.requestRedraw() }, + requestFrame = { popupHost.requestRedraw() }, ) pendingContent?.let { - scene?.setContent(it) + scene?.setContent(content = it) pendingContent = null } @@ -443,7 +445,7 @@ internal class NativeViewOverlayController( } } val sc = scene - if (sc != null) sc.setContent(wrapped) else pendingContent = wrapped + if (sc != null) sc.setContent(content = wrapped) else pendingContent = wrapped popupHost.requestRedraw() } @@ -511,13 +513,13 @@ internal class NativeViewOverlayController( private fun recordSurface(): TaoRecordedSurface? { if (disposed) return null val ctx = directContext ?: return null - val sc = scene ?: return null + val bundle = sceneBundle ?: return null if (widthPx == 0 || heightPx == 0) return null if (attachmentHandle == 0L) return null return TaoRecordedSurface( attachmentHandle = attachmentHandle, directContext = ctx, - picture = recordSceneToPicture(sc, widthPx, heightPx), + picture = recordSceneToPicture(bundle, widthPx, heightPx), clearColor = 0x00000000, isAlive = { !disposed }, ) @@ -530,8 +532,8 @@ internal class NativeViewOverlayController( // Mark disposed before teardown so an already-recorded surface is skipped // at replay time (TaoRecordedSurface.isAlive). disposed = true - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null // Drop the TextureView handle before the context it points at dies. metalTextureHostCache.invalidate() // Close the Skia context on its owning render thread. dispose() runs in diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayControllerWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayControllerWindows.kt index b986c0f63..d8b5fb748 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayControllerWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/NativeViewOverlayControllerWindows.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.scene.ComposeScene -import androidx.compose.ui.scene.PlatformLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize @@ -28,6 +27,8 @@ import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import dev.nucleusframework.window.tao.popup.TaoPopupHostWindows import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerWindows import dev.nucleusframework.window.tao.scene.TaoComposeSceneContext +import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.platformLayersSceneBundle import dev.nucleusframework.window.tao.scene.renderGlFrame import org.jetbrains.skia.DirectContext import kotlin.coroutines.CoroutineContext @@ -256,7 +257,8 @@ internal class NativeViewOverlayControllerWindows( * `popupHost.hostDirectContext` is the source of truth. */ private val directContext: DirectContext = popupHost.hostDirectContext - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene private val regions: MutableMap = LinkedHashMap() private var pendingContent: (@Composable () -> Unit)? = null private var firstBoundsApplied = false @@ -454,28 +456,28 @@ internal class NativeViewOverlayControllerWindows( // HWND owned by the host main HWND. They can extend beyond // the overlay's bounds, intercept their own clicks, and // dismiss on outside-click via the popup's SetCapture monitor. - scene = - PlatformLayersComposeScene( + sceneBundle = + platformLayersSceneBundle( + coroutineContext = popupHost.sceneCoroutineContext, density = Density(scale), layoutDirection = LayoutDirection.Ltr, size = IntSize(widthPx, heightPx), - coroutineContext = popupHost.sceneCoroutineContext, composeSceneContext = TaoComposeSceneContext( platformContext = ourPlatformContext, - ) { density, layoutDirection, focusable, cc -> + ) { density, layoutDirection, focusable, consumeOutside -> TaoPopupSceneLayerWindows( host = overlayPopupHost, initialDensity = density, initialLayoutDirection = layoutDirection, initialFocusable = focusable, - parentCompositionContext = cc, + initialConsumePointerInputOutside = consumeOutside, ) }, - invalidate = { popupHost.requestRedraw() }, + requestFrame = { popupHost.requestRedraw() }, ) pendingContent?.let { - scene?.setContent(it) + scene?.setContent(content = it) pendingContent = null } } else { @@ -496,7 +498,7 @@ internal class NativeViewOverlayControllerWindows( content() } val sc = scene - if (sc != null) sc.setContent(wrapped) else pendingContent = wrapped + if (sc != null) sc.setContent(content = wrapped) else pendingContent = wrapped popupHost.requestRedraw() } @@ -545,7 +547,7 @@ internal class NativeViewOverlayControllerWindows( } private fun renderFrame() { - val sc = scene ?: return + val bundle = sceneBundle ?: return if (widthPx == 0 || heightPx == 0) return if (overlayHandle == 0L) return if (!NativeTaoWindowsOverlayBridge.nativeMakeCurrent(overlayHandle)) return @@ -558,7 +560,7 @@ internal class NativeViewOverlayControllerWindows( widthPx = widthPx, heightPx = heightPx, directContext = directContext, - scene = sc, + bundle = bundle, // Premultiplied transparent black: alpha=0, RGB=0. DWM honors // the alpha channel via the empty-blur-region trick armed at // overlay creation. @@ -574,8 +576,8 @@ internal class NativeViewOverlayControllerWindows( popupHost.unregisterOwnerMoveListener(moveListenerToken) popupHost.unregisterOwnerFocusLostListener(focusLostListenerToken) popupHost.unregisterPopupClosingListener(popupClosingToken) - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null capturedFocusManager = null pressedButtons.clear() manualCursorByKey.clear() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index 1af4156cc..fa7f15950 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -30,6 +29,8 @@ import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import dev.nucleusframework.window.tao.scene.LocalTaoMetalTextureHost import dev.nucleusframework.window.tao.scene.TaoMetalTextureHost import dev.nucleusframework.window.tao.scene.TaoRecordedSurface +import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.recordSceneToPicture import org.jetbrains.skia.DirectContext @@ -76,7 +77,7 @@ internal class TaoPopupSceneLayer( initialDensity: Density, initialLayoutDirection: LayoutDirection, initialFocusable: Boolean, - @Suppress("UNUSED_PARAMETER") parentCompositionContext: CompositionContext, + initialConsumePointerInputOutside: Boolean, ) : ComposeSceneLayer { private var _density = initialDensity private var _layoutDirection = initialLayoutDirection @@ -196,12 +197,12 @@ internal class TaoPopupSceneLayer( override val containerSize: IntSize get() = sceneLayoutSize } - private val innerScene: ComposeScene = - CanvasLayersComposeScene( + private val sceneBundle: TaoSceneBundle = + canvasLayersSceneBundle( + coroutineContext = host.sceneCoroutineContext, density = _density, layoutDirection = _layoutDirection, size = sceneLayoutSize, - coroutineContext = host.sceneCoroutineContext, platformContext = object : PlatformContext.Empty() { override val windowInfo: androidx.compose.ui.platform.WindowInfo @@ -211,9 +212,11 @@ internal class TaoPopupSceneLayer( host.setCursor(pointerIcon.toTaoCursorIconCode()) } }, - invalidate = { host.requestRedraw() }, + requestFrame = { host.requestRedraw() }, ) + private val innerScene: ComposeScene get() = sceneBundle.scene + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -368,6 +371,11 @@ internal class TaoPopupSceneLayer( PopupNativeBridge.nativeSetFocusable(panelHandle, value) } + // Stored for the ComposeSceneLayer contract; the native popup panel handles + // outside-click dismissal via its own NSEvent monitor, so this flag is not + // consulted on the render path. + override var consumePointerInputOutside: Boolean = initialConsumePointerInputOutside + init { // Apply the initial focusable state (constructor sets the field // but the setter is not invoked from a constructor parameter). @@ -384,7 +392,7 @@ internal class TaoPopupSceneLayer( PopupNativeBridge.nativeUninstallOutsideClickMonitor(panelHandle) PopupNativeBridge.nativeSetEventCallback(panelHandle, null) host.setCursor(TaoCursorIcon.DEFAULT) - innerScene.close() + sceneBundle.close() // Close the Skia context on its owning render thread. close() runs in // the host's main-thread record pass (Compose disposal), when the render // thread is idle, so this blocking hop returns immediately and can't race @@ -404,7 +412,10 @@ internal class TaoPopupSceneLayer( PopupNativeBridge.nativeRelease(panelHandle) } - override fun setContent(content: @Composable () -> Unit) { + override fun setContent( + @Suppress("UNUSED_PARAMETER") parentCompositionContext: CompositionContext, + content: @Composable () -> Unit, + ) { innerScene.setContent { // Replay parent locals snapshot so MaterialTheme et al. flow // into the popup content. Compose's popup framework writes @@ -470,7 +481,7 @@ internal class TaoPopupSceneLayer( return TaoRecordedSurface( attachmentHandle = attachmentHandle, directContext = directContext, - picture = recordSceneToPicture(innerScene, widthPx, heightPx), + picture = recordSceneToPicture(sceneBundle, widthPx, heightPx), clearColor = 0x00000000, isAlive = { !disposed }, ) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt index 88850e26b..82c69779a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt @@ -9,14 +9,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -35,7 +33,9 @@ import dev.nucleusframework.window.tao.ffi.NativeTaoEglBridge import dev.nucleusframework.window.tao.releaseGlTextureImports import dev.nucleusframework.window.tao.scene.LocalTaoGlTextureHost import dev.nucleusframework.window.tao.scene.TaoGlTextureHost +import dev.nucleusframework.window.tao.scene.TaoSceneBundle import dev.nucleusframework.window.tao.scene.alignToBufferScale +import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.preservingEglBinding import dev.nucleusframework.window.tao.scene.renderGlFrame import dev.nucleusframework.window.tao.scene.withEglContextCurrent @@ -86,7 +86,7 @@ internal class TaoPopupSceneLayerLinux( initialDensity: Density, initialLayoutDirection: LayoutDirection, initialFocusable: Boolean, - @Suppress("UNUSED_PARAMETER") parentCompositionContext: CompositionContext, + initialConsumePointerInputOutside: Boolean, ) : ComposeSceneLayer { private var _density = initialDensity private var _layoutDirection = initialLayoutDirection @@ -172,12 +172,12 @@ internal class TaoPopupSceneLayerLinux( override val containerSize: IntSize get() = sceneLayoutSize } - private val innerScene: ComposeScene = - CanvasLayersComposeScene( + private val sceneBundle: TaoSceneBundle = + canvasLayersSceneBundle( + coroutineContext = host.sceneCoroutineContext, density = _density, layoutDirection = _layoutDirection, size = sceneLayoutSize, - coroutineContext = host.sceneCoroutineContext, platformContext = object : PlatformContext.Empty() { override val windowInfo: androidx.compose.ui.platform.WindowInfo @@ -191,9 +191,11 @@ internal class TaoPopupSceneLayerLinux( ) } }, - invalidate = { host.requestRedraw() }, + requestFrame = { host.requestRedraw() }, ) + private val innerScene: ComposeScene get() = sceneBundle.scene + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -317,6 +319,11 @@ internal class TaoPopupSceneLayerLinux( _focusable = value } + // Stored for the ComposeSceneLayer contract; outside-press dismissal is + // handled via the parent scene's forwarded press listener, so this flag is + // not consulted on the render path. + override var consumePointerInputOutside: Boolean = initialConsumePointerInputOutside + override fun close() { if (released) return released = true @@ -327,7 +334,7 @@ internal class TaoPopupSceneLayerLinux( // Drop the TextureView handle before the context it points at dies: a // late composition must not import onto a closed context. glTextureHostState.value = null - innerScene.close() + sceneBundle.close() if (attachment != 0L) { // A layer closes when Compose drops it — from the owner's // composition, i.e. inside the window scene's render pass. Binding @@ -352,7 +359,10 @@ internal class TaoPopupSceneLayerLinux( popupWindow.requestClose() } - override fun setContent(content: @Composable () -> Unit) { + override fun setContent( + @Suppress("UNUSED_PARAMETER") parentCompositionContext: CompositionContext, + content: @Composable () -> Unit, + ) { innerScene.setContent { val locals = _compositionLocalContext // Our texture host goes *inside* the replayed locals: those carry @@ -465,7 +475,7 @@ internal class TaoPopupSceneLayerLinux( canvas.save() try { canvas.translate(-frame.left.toFloat(), -frame.top.toFloat()) - innerScene.render(canvas.asComposeCanvas(), nanoTime) + sceneBundle.render(canvas, nanoTime) } finally { canvas.restore() } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt index 6a7d7b26a..357f75f2b 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType @@ -17,7 +16,6 @@ import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -28,6 +26,8 @@ import androidx.compose.ui.unit.LayoutDirection import dev.nucleusframework.window.tao.event.dispatchNativeKeyEvent import dev.nucleusframework.window.tao.ffi.PopupNativeBridgeWindows import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat +import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.renderGlFrame import org.jetbrains.skia.DirectContext @@ -57,7 +57,7 @@ internal class TaoPopupSceneLayerWindows( initialDensity: Density, initialLayoutDirection: LayoutDirection, initialFocusable: Boolean, - @Suppress("UNUSED_PARAMETER") parentCompositionContext: CompositionContext, + initialConsumePointerInputOutside: Boolean, ) : ComposeSceneLayer { private var _density = initialDensity private val densityState: MutableState = mutableStateOf(initialDensity) @@ -162,20 +162,22 @@ internal class TaoPopupSceneLayerWindows( override val containerSize: IntSize get() = sceneLayoutSize } - private val innerScene: ComposeScene = - CanvasLayersComposeScene( + private val sceneBundle: TaoSceneBundle = + canvasLayersSceneBundle( + coroutineContext = host.sceneCoroutineContext, density = _density, layoutDirection = _layoutDirection, size = sceneLayoutSize, - coroutineContext = host.sceneCoroutineContext, platformContext = object : PlatformContext.Empty() { override val windowInfo: androidx.compose.ui.platform.WindowInfo get() = popupWindowInfo }, - invalidate = { host.requestRedraw() }, + requestFrame = { host.requestRedraw() }, ) + private val innerScene: ComposeScene get() = sceneBundle.scene + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -312,6 +314,11 @@ internal class TaoPopupSceneLayerWindows( if (panelHandle != 0L) PopupNativeBridgeWindows.nativeSetFocusable(panelHandle, value) } + // Stored for the ComposeSceneLayer contract; the native popup HWND handles + // outside-click dismissal via its own SetCapture monitor, so this flag is + // not consulted on the render path. + override var consumePointerInputOutside: Boolean = initialConsumePointerInputOutside + override fun close() { released = true host.notifyPopupClosing() @@ -319,11 +326,14 @@ internal class TaoPopupSceneLayerWindows( host.unregisterOwnerMoveListener(moveListenerToken) PopupNativeBridgeWindows.nativeUninstallOutsideClickMonitor(panelHandle) PopupNativeBridgeWindows.nativeSetEventCallback(panelHandle, null) - innerScene.close() + sceneBundle.close() PopupNativeBridgeWindows.nativeRelease(panelHandle) } - override fun setContent(content: @Composable () -> Unit) { + override fun setContent( + @Suppress("UNUSED_PARAMETER") parentCompositionContext: CompositionContext, + content: @Composable () -> Unit, + ) { innerScene.setContent { val locals = _compositionLocalContext val body: @Composable () -> Unit = { @@ -384,7 +394,7 @@ internal class TaoPopupSceneLayerWindows( canvas.save() try { canvas.translate(-frame.left.toFloat(), -frame.top.toFloat()) - innerScene.render(canvas.asComposeCanvas(), nanoTime) + sceneBundle.render(canvas, nanoTime) } finally { canvas.restore() } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt index 83dfd35ca..153aec6b9 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt @@ -1,13 +1,11 @@ package dev.nucleusframework.window.tao.popup -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType @@ -15,7 +13,6 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfo -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -30,7 +27,9 @@ import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import dev.nucleusframework.window.tao.releaseWindowsTextureImports import dev.nucleusframework.window.tao.scene.LocalTaoWindowsTextureHost import dev.nucleusframework.window.tao.scene.TaoComposeSceneHostWindows +import dev.nucleusframework.window.tao.scene.TaoSceneBundle import dev.nucleusframework.window.tao.scene.TaoWindowsTextureHost +import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.preservingAngleBinding import dev.nucleusframework.window.tao.scene.renderGlFrame import org.jetbrains.skia.DirectContext @@ -66,7 +65,8 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { private var panel: Long = 0 private var directContext: DirectContext? = null - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene private var disposed = false /** @@ -83,7 +83,6 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { override var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null override var onKeyEvent: ((KeyEvent) -> Boolean)? = null - private val frameClock = BroadcastFrameClock { scheduleRender() } private val flushingDispatcher = FlushingDispatcher() private val windowInfo = StandalonePopupWindowInfo() @@ -150,14 +149,14 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { null } if (directContext != null) { - scene = - CanvasLayersComposeScene( + sceneBundle = + canvasLayersSceneBundle( + coroutineContext = flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, size = IntSize(1, 1), - coroutineContext = flushingDispatcher + frameClock, platformContext = StandalonePopupPlatformContext(), - invalidate = { scheduleRender() }, + requestFrame = { scheduleRender() }, ) PopupNativeBridgeWindows.nativeSetEventCallback(panel, PanelEventCallback()) publishTextureHost() @@ -178,7 +177,7 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { } override fun setContent(content: @Composable () -> Unit) { - scene?.setContent(content) + scene?.setContent(content = content) scheduleRender() } @@ -319,8 +318,8 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { preservingAngleBinding { // Drop the TextureView handle before the context it points at dies. textureHostState.value = null - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null // An ownerless panel binds the immortal headless EGL context, not // the caller's — so the Skia frees below need it made current // explicitly, and they can't disturb any window host's GL state. @@ -350,7 +349,7 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { renderPending.set(false) if (disposed) return val ctx = directContext ?: return - val sc = scene ?: return + val bundle = sceneBundle ?: return if (widthPx <= 0 || heightPx <= 0) return // Pace self-invalidating content (animations): DComp presents don't @@ -375,10 +374,10 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { val frameNs = if (now - nextFrameNs > FRAME_INTERVAL_NS) now else nextFrameNs nextFrameNs = frameNs + FRAME_INTERVAL_NS - // Tick the frame clock before rendering (same ordering as the window - // hosts) so withFrameNanos-driven animation state is current. - flushingDispatcher.drain() - frameClock.sendFrame(frameNs) + // Drain queued main-thread work before the frame. The scene's frame + // clock is ticked inside `bundle.render` (FrameRecomposer.performFrame) + // with the paced `frameNs` timestamp, so withFrameNanos-driven + // animations are fed evenly spaced times for smooth motion. flushingDispatcher.drain() // Surface-neutral, like the bring-up: whatever was bound before this @@ -397,8 +396,8 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { directContext = ctx, clearColorArgb = 0x00000000, present = { PopupNativeBridgeWindows.nativeSwapBuffers(panel) }, - ) { canvas, nanoTime -> - sc.render(canvas.asComposeCanvas(), nanoTime) + ) { canvas, _ -> + bundle.render(canvas, frameNs) } } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt index f2ec5d92d..274703c29 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt @@ -1,13 +1,11 @@ package dev.nucleusframework.window.tao.popup -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType @@ -15,7 +13,6 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfo -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -30,6 +27,8 @@ import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import dev.nucleusframework.window.tao.releaseGlTextureImports import dev.nucleusframework.window.tao.scene.LocalTaoGlTextureHost import dev.nucleusframework.window.tao.scene.TaoGlTextureHost +import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.preservingEglBinding import dev.nucleusframework.window.tao.scene.renderGlFrame import dev.nucleusframework.window.tao.scene.withEglContextCurrent @@ -74,7 +73,8 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { private var panel: Long = 0 private var attachment: Long = 0 private var directContext: DirectContext? = null - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene private var disposed = false /** @@ -90,7 +90,6 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { override var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null override var onKeyEvent: ((KeyEvent) -> Boolean)? = null - private val frameClock = BroadcastFrameClock { scheduleRender() } private val flushingDispatcher = FlushingDispatcher() private val windowInfo = StandalonePopupWindowInfo() @@ -184,14 +183,14 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { panel = 0 return false } - scene = - CanvasLayersComposeScene( + sceneBundle = + canvasLayersSceneBundle( + coroutineContext = flushingDispatcher, density = Density(panelScale), layoutDirection = GlobalLayoutDirection, size = IntSize(1, 1), - coroutineContext = flushingDispatcher + frameClock, platformContext = StandalonePopupPlatformContext(), - invalidate = { scheduleRender() }, + requestFrame = { scheduleRender() }, ) PopupNativeBridgeLinux.nativeSetEventCallback(panel, PanelEventCallback()) publishGlTextureHost() @@ -200,7 +199,7 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { } override fun setContent(content: @Composable () -> Unit) { - scene?.setContent(content) + scene?.setContent(content = content) scheduleRender() } @@ -317,8 +316,8 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { preservingEglBinding { // Drop the TextureView handle before the context it points at dies. glTextureHostState.value = null - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null NativeTaoEglBridge.nativeMakeCurrent(attachment) // Belt for imports a leaked composition may still hold; scene.close() // above released the leases of every live one. @@ -344,7 +343,7 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { renderPending.set(false) if (disposed) return val ctx = directContext ?: return - val sc = scene ?: return + val bundle = sceneBundle ?: return if (widthPx <= 0 || heightPx <= 0) return // Keep the scene's coroutine work (recomposer steps, effects) moving @@ -372,9 +371,10 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { val frameNs = if (now - nextFrameNs > FRAME_INTERVAL_NS) now else nextFrameNs nextFrameNs = frameNs + FRAME_INTERVAL_NS - // Tick the frame clock before rendering (same ordering as the window - // hosts) so withFrameNanos-driven animation state is current. - frameClock.sendFrame(frameNs) + // Drain queued main-thread work before the frame. The scene's frame + // clock is ticked inside `bundle.render` (FrameRecomposer.performFrame) + // with the paced `frameNs` timestamp, so withFrameNanos-driven + // animations are fed evenly spaced times for smooth motion. flushingDispatcher.drain() // Context-neutral, like the bring-up: whatever bound the thread's context @@ -390,8 +390,8 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { directContext = ctx, clearColorArgb = 0x00000000, present = { NativeTaoEglBridge.nativePresent(attachment) }, - ) { canvas, nanoTime -> - sc.render(canvas.asComposeCanvas(), nanoTime) + ) { canvas, _ -> + bundle.render(canvas, frameNs) } } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt index a825318ee..d55b26226 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostMac.kt @@ -1,6 +1,5 @@ package dev.nucleusframework.window.tao.popup -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.InternalComposeUiApi @@ -12,7 +11,6 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.WindowInfo -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -28,6 +26,8 @@ import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import dev.nucleusframework.window.tao.scene.LocalTaoMetalTextureHost import dev.nucleusframework.window.tao.scene.MetalTextureHostCache import dev.nucleusframework.window.tao.scene.TaoMetalTextureHost +import dev.nucleusframework.window.tao.scene.TaoSceneBundle +import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.newMetalRenderExecutor import dev.nucleusframework.window.tao.scene.recordSceneToPicture import dev.nucleusframework.window.tao.scene.replayPictureToFrame @@ -71,7 +71,8 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { private var panel: Long = 0 private var attachmentHandle: Long = 0 private var directContext: DirectContext? = null - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene private var disposed = false /** @@ -86,7 +87,6 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { override var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null override var onKeyEvent: ((KeyEvent) -> Boolean)? = null - private val frameClock = BroadcastFrameClock { scheduleRender() } private val flushingDispatcher = FlushingDispatcher() private val windowInfo = StandalonePopupWindowInfo() @@ -137,14 +137,14 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { val queuePtr = NativeMetalBridge.nativeQueuePtr(attachmentHandle) directContext = runOnRenderThread { DirectContext.makeMetal(devicePtr, queuePtr) } - scene = - CanvasLayersComposeScene( + sceneBundle = + canvasLayersSceneBundle( + coroutineContext = flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, size = IntSize(1, 1), - coroutineContext = flushingDispatcher + frameClock, platformContext = StandalonePopupPlatformContext(), - invalidate = { scheduleRender() }, + requestFrame = { scheduleRender() }, ) PopupNativeBridge.nativeSetEventCallback(panel, PanelEventCallback()) PopupNativeBridge.nativeOrderOut(panel) // hidden until first setVisible(true) @@ -157,7 +157,7 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { } override fun setContent(content: @Composable () -> Unit) { - scene?.setContent(content) + scene?.setContent(content = content) scheduleRender() } @@ -273,8 +273,8 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { disposed = true PopupNativeBridge.nativeUninstallOutsideClickMonitor(panel) PopupNativeBridge.nativeSetEventCallback(panel, null) - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null metalTextureHostCache.invalidate() val ctx = directContext directContext = null @@ -298,7 +298,7 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { private fun renderNow() { renderPending.set(false) if (disposed) return - val sc = scene ?: return + val bundle = sceneBundle ?: return val ctx = directContext ?: return val handle = attachmentHandle if (handle == 0L || widthPx <= 0 || heightPx <= 0) return @@ -342,9 +342,10 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { // Record on the main thread (Compose state lives here). The Picture is // a thread-safe snapshot — safe to hand to the render thread for replay. - frameClock.sendFrame(frameNs) + // `recordSceneToPicture` ticks the scene's frame clock with the paced + // `frameNs` (via FrameRecomposer.performFrame) before drawing. flushingDispatcher.drain() - val picture = recordSceneToPicture(sc, widthPx, heightPx, frameNs) + val picture = recordSceneToPicture(bundle, widthPx, heightPx, frameNs) replayInFlight.set(true) renderExecutor.submit { try { @@ -369,16 +370,14 @@ internal class TaoStandalonePopupHostMac : StandalonePopupHost { */ private fun renderFrameBlocking() { if (disposed) return - val sc = scene ?: return + val bundle = sceneBundle ?: return val ctx = directContext ?: return val handle = attachmentHandle if (handle == 0L || widthPx <= 0 || heightPx <= 0) return flushingDispatcher.drain() val frameNs = System.nanoTime() nextFrameNs = frameNs + FRAME_INTERVAL_NS - frameClock.sendFrame(frameNs) - flushingDispatcher.drain() - val picture = recordSceneToPicture(sc, widthPx, heightPx, frameNs) + val picture = recordSceneToPicture(bundle, widthPx, heightPx, frameNs) runOnRenderThread { try { replayPictureToFrame(handle, ctx, picture, clearColor = 0x00000000) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt index 9ef2cd73a..5c3dad332 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt @@ -1,8 +1,5 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.ui.InternalComposeUiApi -import androidx.compose.ui.graphics.asComposeCanvas -import androidx.compose.ui.scene.ComposeScene import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.ColorSpace import org.jetbrains.skia.DirectContext @@ -22,12 +19,11 @@ import org.jetbrains.skia.SurfaceOrigin * GL state cache reflects reality after the external surface switch, * 3. provide [present] (the bridge's `nativeSwapBuffers`). */ -@OptIn(InternalComposeUiApi::class) internal inline fun renderGlFrame( widthPx: Int, heightPx: Int, directContext: DirectContext, - scene: ComposeScene, + bundle: TaoSceneBundle, clearColorArgb: Int, crossinline present: () -> Unit, ) { @@ -38,11 +34,10 @@ internal inline fun renderGlFrame( clearColorArgb = clearColorArgb, present = present, ) { canvas, nanoTime -> - scene.render(canvas.asComposeCanvas(), nanoTime) + bundle.render(canvas, nanoTime) } } -@OptIn(InternalComposeUiApi::class) internal inline fun renderGlFrame( widthPx: Int, heightPx: Int, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt index 0709ec080..d64ae8d7d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt @@ -1,8 +1,5 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.ui.InternalComposeUiApi -import androidx.compose.ui.graphics.asComposeCanvas -import androidx.compose.ui.scene.ComposeScene import dev.nucleusframework.window.tao.ffi.NativeMetalBridge import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.ColorSpace @@ -39,9 +36,8 @@ import org.jetbrains.skia.SurfaceOrigin * drawable wraps a different texture, so the per-frame allocations in * [replayPictureToFrame] are unavoidable. */ -@OptIn(InternalComposeUiApi::class) internal fun recordSceneToPicture( - scene: ComposeScene, + bundle: TaoSceneBundle, widthPx: Int, heightPx: Int, nanoTime: Long = System.nanoTime(), @@ -50,7 +46,7 @@ internal fun recordSceneToPicture( // The cull bounds match the drawable size (physical pixels). The scene is // rendered at this size; the clear happens at replay time, not here. val canvas = recorder.beginRecording(Rect.makeWH(widthPx.toFloat(), heightPx.toFloat())) - scene.render(canvas.asComposeCanvas(), nanoTime) + bundle.render(canvas, nanoTime) // Closing the recorder here frees its native memory deterministically // (one recorder per frame — a GC-driven Cleaner would lag far behind); // the returned Picture owns its own native ref and survives the close. diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt index f3824cbf7..d97a54740 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt @@ -1,6 +1,5 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.runtime.CompositionContext import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.scene.ComposeSceneContext @@ -31,13 +30,13 @@ internal class TaoComposeSceneContext( density: Density, layoutDirection: LayoutDirection, focusable: Boolean, - compositionContext: CompositionContext, + consumePointerInputOutside: Boolean, ) -> ComposeSceneLayer, ) : ComposeSceneContext { override fun createLayer( density: Density, layoutDirection: LayoutDirection, focusable: Boolean, - compositionContext: CompositionContext, - ): ComposeSceneLayer = layerFactory(density, layoutDirection, focusable, compositionContext) + consumePointerInputOutside: Boolean, + ): ComposeSceneLayer = layerFactory(density, layoutDirection, focusable, consumePointerInputOutside) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index 00305cd73..2893c650b 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -2,7 +2,6 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue @@ -15,10 +14,8 @@ import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer -import androidx.compose.ui.scene.PlatformLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize @@ -187,11 +184,11 @@ internal class TaoComposeSceneHost( private var attachmentHandle: Long = 0 private var nsViewHandle: Long = 0 private var directContext: DirectContext? = null - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null - private val frameClock = BroadcastFrameClock() // Dispatcher that funnels Compose's async work (notably MouseWheel scroll // dispatching, which uses the scene's coroutineContext) onto the render @@ -337,12 +334,6 @@ internal class TaoComposeSceneHost( prepareFullscreenFrame(targetW, targetH) } - // CRITICAL: provide our own MonotonicFrameClock (BroadcastFrameClock) - // in the scene's coroutineContext. Without one, Compose's recomposer - // can't tell when a frame has finished and re-fires `invalidate` after - // every render — causing a continuous render loop that saturates the - // main thread. We tick the clock manually at the end of each - // onRedrawRequested. // The DnD manager needs lazy access to the scene's rootDragAndDropNode, // but the scene cannot be constructed before we hand it the // PlatformContext that owns the manager. Resolve on each call. @@ -380,51 +371,54 @@ internal class TaoComposeSceneHost( ) val hostPopupHost = if (nativePopupLayers) popupHost() else null - scene = + // The scene's MonotonicFrameClock is owned by the FrameRecomposer inside the + // bundle (Compose 1.12). It matters that the clock exists: without one the + // recomposer can't tell when a frame finished and re-fires the invalidation + // after every render, saturating the main thread. The recomposer now ticks it + // itself in `performFrame` (one frame per FrameDispatcher tick, re-scheduling + // only while animations remain), so the host no longer sends frames manually. + sceneBundle = if (hostPopupHost != null) { // Opt-in path (e.g. tray popups): every Popup becomes a native // NSPanel owned by this window, so popup content can extend // beyond — and float independently of — the window bounds. - PlatformLayersComposeScene( + platformLayersSceneBundle( + coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, size = IntSize(widthPx, heightPx), - coroutineContext = coroutineContext + frameClock + flushingDispatcher, composeSceneContext = TaoComposeSceneContext( platformContext = taoPlatformContext, - ) { density, layoutDirection, focusable, cc -> + ) { density, layoutDirection, focusable, consumeOutside -> TaoPopupSceneLayer( host = hostPopupHost, initialDensity = density, initialLayoutDirection = layoutDirection, initialFocusable = focusable, - parentCompositionContext = cc, + initialConsumePointerInputOutside = consumeOutside, ) }, - invalidate = { - frameDispatcher?.scheduleFrame() - }, - ).apply { compositionLocalContext = pendingCompositionLocalContext } + // Schedule a frame on the render loop (coalesced); it renders + // then waits for the next vsync. See startRenderLoop. + requestFrame = { frameDispatcher?.scheduleFrame() }, + ) } else { // Match Windows and Linux for the main host scene: Compose // Popup / DropdownMenu / Tooltip content stays in the same // Metal render target instead of becoming a native NSPanel. // NativeView overlay scenes still opt into TaoComposeSceneContext // when their popups must float above an embedded AppKit view. - CanvasLayersComposeScene( + canvasLayersSceneBundle( + coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, size = IntSize(widthPx, heightPx), - coroutineContext = coroutineContext + frameClock + flushingDispatcher, platformContext = taoPlatformContext, - invalidate = { - // Schedule a frame on the render loop (coalesced); it renders - // then waits for the next vsync. See startRenderLoop. - frameDispatcher?.scheduleFrame() - }, - ).apply { compositionLocalContext = pendingCompositionLocalContext } + requestFrame = { frameDispatcher?.scheduleFrame() }, + ) } + scene?.compositionLocalContext = pendingCompositionLocalContext registerInboundDnD() } @@ -793,7 +787,7 @@ internal class TaoComposeSceneHost( return if (w > 0 && h > 0) IntSize(w, h) else parentWindowSize } override val sceneCoroutineContext: CoroutineContext - get() = outer.coroutineContext + outer.frameClock + outer.flushingDispatcher + get() = outer.coroutineContext + outer.flushingDispatcher override fun requestRedraw() = outer.window.requestRedraw() @@ -1298,7 +1292,7 @@ internal class TaoComposeSceneHost( * frees the Tao main loop during GPU encode + present + vsync wait. */ private suspend fun renderFrameSuspending(handle: Long) { - val sc = scene ?: return + val bundle = sceneBundle ?: return val ctx = directContext ?: return if (attachmentHandle == 0L || widthPx <= 0 || heightPx <= 0) return @@ -1324,7 +1318,7 @@ internal class TaoComposeSceneHost( // fullscreen/title-bar animation gaps don't flash. The clear itself runs // at replay time on the recorded surface. val mainClear = if (glassBackgroundState.value) 0 else clearColorArgbState.value - val mainPicture = recordSceneToPicture(sc, widthPx, heightPx) + val mainPicture = recordSceneToPicture(bundle, widthPx, heightPx) val popupSurfaces = recordPopupSurfaces() // Drain Compose's async work (sendFrame continuations, recomposer steps) // synchronously so their state writes happen now and trigger invalidate → @@ -1418,11 +1412,11 @@ internal class TaoComposeSceneHost( * [renderFrameSuspending]. */ fun renderFrameBlocking() { - val sc = scene ?: return + val bundle = sceneBundle ?: return val ctx = directContext ?: return if (attachmentHandle == 0L || widthPx <= 0 || heightPx <= 0) return val mainClear = if (glassBackgroundState.value) 0 else clearColorArgbState.value - val mainPicture = recordSceneToPicture(sc, widthPx, heightPx) + val mainPicture = recordSceneToPicture(bundle, widthPx, heightPx) val popupSurfaces = recordPopupSurfaces() TaoMainDispatcher.pump() val handle = attachmentHandle @@ -1448,8 +1442,8 @@ internal class TaoComposeSceneHost( frameDispatcher = null renderLoopJob.cancel() textToolbar.hide() - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null // Drop the TextureView handle before the context it points at dies. metalTextureHostCache.invalidate() // Close the DirectContext on its owning thread (FIFO after any in-flight @@ -1486,8 +1480,8 @@ internal class TaoComposeSceneHost( * pumps queued blocks on every `Event::MainEventsCleared` tick of the * Tao loop. We also call `window.requestRedraw()` so the loop is woken * if it was idle — without it, animations driven by `withFrameNanos` - * (whose continuations land here when `frameClock.sendFrame` fires - * inside `BaseComposeScene.recompose`) would freeze until input arrives. + * (whose continuations land here when `FrameRecomposer.performFrame` + * ticks the scene's frame clock) would freeze until input arrives. * * The auto-pump matters: in the previous implementation, blocks only * ran during [onRedrawRequested]'s explicit drain — a chicken-and-egg diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index 70e6e3d6d..8166a0da4 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -2,7 +2,6 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -11,16 +10,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.input.pointer.PointerType -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer -import androidx.compose.ui.scene.PlatformLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset @@ -182,7 +178,8 @@ internal class TaoComposeSceneHostLinux( private var currentKeyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers() private var attachmentHandle: Long = 0 private var directContext: DirectContext? = null - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene /** * Handle `TextureView`s in this window's scene import onto — see @@ -202,7 +199,6 @@ internal class TaoComposeSceneHostLinux( /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null - private val frameClock = BroadcastFrameClock() private val flushingDispatcher = FlushingMainDispatcher() /** Floating text-selection bar shown on touch selection. */ @@ -460,45 +456,42 @@ internal class TaoComposeSceneHostLinux( dragAndDropManager = dndManager, textToolbar = textToolbar, ) - scene = + sceneBundle = if (nativePopupLayers) { // Opt-in path: every Popup becomes a Tao popup window owned by // this window (override-redirect on X11, wl_subsurface on // Wayland), so popup content can extend beyond — and float // independently of — the window bounds. - PlatformLayersComposeScene( + platformLayersSceneBundle( + coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, - coroutineContext = coroutineContext + frameClock + flushingDispatcher, composeSceneContext = TaoComposeSceneContext( platformContext = platformContext, - ) { density, layoutDirection, focusable, cc -> + ) { density, layoutDirection, focusable, consumeOutside -> TaoPopupSceneLayerLinux( host = popupHost(), initialDensity = density, initialLayoutDirection = layoutDirection, initialFocusable = focusable, - parentCompositionContext = cc, + initialConsumePointerInputOutside = consumeOutside, ) }, - invalidate = { - requestRedrawCoalesced() - }, - ).apply { compositionLocalContext = pendingCompositionLocalContext } + requestFrame = { requestRedrawCoalesced() }, + ) } else { // Default: Compose Popup / DropdownMenu / Tooltip content stays // in the same EGL render target as the rest of the UI. - CanvasLayersComposeScene( + canvasLayersSceneBundle( + coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, - coroutineContext = coroutineContext + frameClock + flushingDispatcher, platformContext = platformContext, - invalidate = { - requestRedrawCoalesced() - }, - ).apply { compositionLocalContext = pendingCompositionLocalContext } + requestFrame = { requestRedrawCoalesced() }, + ) } + scene?.compositionLocalContext = pendingCompositionLocalContext // Notify popup layers when the host window moves on screen — X11 // popups are positioned in root coordinates and don't auto-track. @@ -1430,16 +1423,16 @@ internal class TaoComposeSceneHostLinux( skipDrainBudget = SKIP_DRAIN_BUDGET_PER_FRAME val ctx = directContext ?: return - val sc = scene ?: return + val bundle = sceneBundle ?: return if (widthPx <= 0 || heightPx <= 0) return val now = System.nanoTime() - // Same frame-clock ordering as the Windows path: tick before render so - // `withFrameNanos`-driven animations apply on the current frame instead - // of lagging by one. - flushingDispatcher.drain() - frameClock.sendFrame(now) + // Drain queued main-thread work before the frame. The scene's frame + // clock is ticked inside `bundle.render` (FrameRecomposer.performFrame), + // so `withFrameNanos`-driven animations apply on the current frame + // instead of lagging by one — same guarantee as before, now atomic with + // the recompose → layout → draw the render call performs. flushingDispatcher.drain() NativeTaoEglBridge.nativeMakeCurrent(attachmentHandle) @@ -1449,8 +1442,8 @@ internal class TaoComposeSceneHostLinux( updateResizeBurstSwapInterval() val paintSize = resolvePaintSize() - if (sc.size != paintSize) { - sc.size = paintSize + if (bundle.scene.size != paintSize) { + bundle.scene.size = paintSize lastSceneSizeUpdateNs = now } @@ -1463,8 +1456,9 @@ internal class TaoComposeSceneHostLinux( // a transparent clear. The rounded corners are carved back to // transparent by [applyFrameDecoration] below. surface.canvas.clear(clearColorArgbState.value) - sc.render(surface.canvas.asComposeCanvas(), now) + bundle.render(surface.canvas, now) applyFrameDecoration(surface.canvas, paintSize.width, paintSize.height) + surface.flushAndSubmit(syncCpu = false) NativeTaoEglBridge.nativeReleaseCurrent(attachmentHandle) swapThread?.requestSwap() @@ -1961,7 +1955,7 @@ internal class TaoComposeSceneHostLinux( override val coordinateOffset: IntOffset get() = IntOffset.Zero override val sceneCoroutineContext: CoroutineContext - get() = outer.coroutineContext + outer.frameClock + outer.flushingDispatcher + get() = outer.coroutineContext + outer.flushingDispatcher override fun requestRedraw() = outer.requestRedrawCoalesced() @@ -2200,8 +2194,8 @@ internal class TaoComposeSceneHostLinux( // to free its Skia resources — leaving that context current. The // host re-bind below must come after so the host's GPU releases land // on the right context. - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null // Re-bind THIS window's EGL context before tearing down Skia. The // GPU-resource releases that follow (glDeleteFramebuffers / diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index e2ffa9333..4477414b9 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -2,7 +2,6 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.MutableState @@ -12,17 +11,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.scene.CanvasLayersComposeScene + import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer -import androidx.compose.ui.scene.PlatformLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntSize @@ -174,11 +171,12 @@ internal class TaoComposeSceneHostWindows( */ val windowsTextureHostState: MutableState = mutableStateOf(null) - private var scene: ComposeScene? = null + private var sceneBundle: TaoSceneBundle? = null + private val scene: ComposeScene? get() = sceneBundle?.scene + /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null - private val frameClock = BroadcastFrameClock() private val flushingDispatcher = FlushingMainDispatcher() /** @@ -188,7 +186,7 @@ internal class TaoComposeSceneHostWindows( * scheduler. Cancelled in [detach]. */ private val gestureScope = - CoroutineScope(coroutineContext + flushingDispatcher + frameClock + SupervisorJob()) + CoroutineScope(coroutineContext + flushingDispatcher + SupervisorJob()) /** Floating text-selection bar shown on touch selection. */ private val textToolbar = TaoTextToolbar() @@ -365,40 +363,41 @@ internal class TaoComposeSceneHostWindows( dragAndDropManager = dndManager, textToolbar = textToolbar, ) - scene = + sceneBundle = if (nativePopupLayers) { // Opt-in path (e.g. tray popups): every Popup becomes a // transparent WS_POPUP HWND owned by this window, so popup // content can extend beyond — and float independently of — // the window bounds. popupHost() is non-null here: hwnd and // directContext were both set above. - PlatformLayersComposeScene( + platformLayersSceneBundle( + coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, - coroutineContext = coroutineContext + frameClock + flushingDispatcher, composeSceneContext = TaoComposeSceneContext( platformContext = platformContext, - ) { density, layoutDirection, focusable, cc -> + ) { density, layoutDirection, focusable, consumeOutside -> TaoPopupSceneLayerWindows( host = requireNotNull(popupHost()), initialDensity = density, initialLayoutDirection = layoutDirection, initialFocusable = focusable, - parentCompositionContext = cc, + initialConsumePointerInputOutside = consumeOutside, ) }, - invalidate = { window.requestRedraw() }, - ).apply { compositionLocalContext = pendingCompositionLocalContext } + requestFrame = { window.requestRedraw() }, + ) } else { - CanvasLayersComposeScene( + canvasLayersSceneBundle( + coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, - coroutineContext = coroutineContext + frameClock + flushingDispatcher, platformContext = platformContext, - invalidate = { window.requestRedraw() }, - ).apply { compositionLocalContext = pendingCompositionLocalContext } + requestFrame = { window.requestRedraw() }, + ) } + scene?.compositionLocalContext = pendingCompositionLocalContext publishWindowsTextureHost() registerInboundDnD() @@ -907,26 +906,13 @@ internal class TaoComposeSceneHostWindows( targetWidthPx: Int, targetHeightPx: Int, ) { - val sc = scene ?: return + val bundle = sceneBundle ?: return if (targetWidthPx <= 0 || targetHeightPx <= 0) return - sc.size = IntSize(targetWidthPx, targetHeightPx) + bundle.scene.size = IntSize(targetWidthPx, targetHeightPx) // Apply pending snapshot writes (the chrome flip pushed just before // this call) so the warmed layout already has the right chrome. flushingDispatcher.drain() - frameClock.sendFrame(System.nanoTime()) - flushingDispatcher.drain() - val recorder = org.jetbrains.skia.PictureRecorder() - try { - val canvas = - recorder.beginRecording( - org.jetbrains.skia.Rect - .makeWH(targetWidthPx.toFloat(), targetHeightPx.toFloat()), - ) - sc.render(canvas.asComposeCanvas(), System.nanoTime()) - recorder.finishRecordingAsPicture().close() - } finally { - recorder.close() - } + recordSceneToPicture(bundle, targetWidthPx, targetHeightPx).close() } /** @@ -1129,7 +1115,8 @@ internal class TaoComposeSceneHostWindows( fun onRedrawRequested() { val ctx = directContext ?: return - val sc = scene ?: return + val bundle = sceneBundle ?: return + val sc = bundle.scene if (widthPx <= 0 || heightPx <= 0) return @@ -1158,16 +1145,12 @@ internal class TaoComposeSceneHostWindows( val now = System.nanoTime() - // ── Frame clock ordering ────────────────────────────────────────── - // Tick the frame clock BEFORE rendering and drain twice. Without this - // the smooth-scroll animation (and any other `withFrameNanos`-driven - // animation) lags one frame behind: `sendFrame` resumes the awaiting - // continuations which then mutate state, but if we render first the - // composition reads the *previous* frame's state. JNI / Skiko's - // default loop ticks before render, so to match that feel we mirror - // the order here. - flushingDispatcher.drain() - frameClock.sendFrame(now) + // ── Frame pump ──────────────────────────────────────────────────── + // Drain queued main-thread work (scroll dispatch, a11y, etc.) before + // the frame. The scene's frame clock is ticked inside `bundle.render` + // (via FrameRecomposer.performFrame), so `withFrameNanos`-driven + // animation state is resumed and applied atomically with this frame's + // recompose → layout → draw — no one-frame lag. flushingDispatcher.drain() // Make sure the ES context + host window surface are current on this @@ -1231,7 +1214,8 @@ internal class TaoComposeSceneHostWindows( // colour (alpha-0 by default, or a semi-transparent WindowBackground). // Opaque windows: themed clear as usual. surface.canvas.clear(resolveClientClearArgb()) - sc.render(surface.canvas.asComposeCanvas(), now) + bundle.render(surface.canvas, now) + // `flushAndSubmit` issues the glFlush that commits the frame to // the back buffer; the present happens below, after the overlay/ // popup renderers (they only need the flush, not the present). @@ -1485,7 +1469,7 @@ internal class TaoComposeSceneHostWindows( return IntSize(w, h) } override val sceneCoroutineContext: kotlin.coroutines.CoroutineContext - get() = outer.coroutineContext + outer.frameClock + outer.flushingDispatcher + get() = outer.coroutineContext + outer.flushingDispatcher override val hostDirectContext: DirectContext get() = ctx override fun requestRedraw() = outer.window.requestRedraw() @@ -1684,8 +1668,8 @@ internal class TaoComposeSceneHostWindows( if (attachmentHandle != 0L) { NativeTaoGlBridge.nativeMakeCurrent(attachmentHandle) } - scene?.close() - scene = null + sceneBundle?.close() + sceneBundle = null if (directContext != null) { // Belt for TextureView imports a leaked composition may still hold: // scene.close() above released the leases of every live one. They diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt new file mode 100644 index 000000000..6bbaeda9e --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt @@ -0,0 +1,113 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.graphics.asComposeCanvas +import androidx.compose.ui.platform.FrameRecomposer +import androidx.compose.ui.platform.PlatformContext +import androidx.compose.ui.scene.CanvasLayersComposeScene +import androidx.compose.ui.scene.ComposeScene +import androidx.compose.ui.scene.ComposeSceneContext +import androidx.compose.ui.scene.PlatformLayersComposeScene +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import org.jetbrains.skia.Canvas +import kotlin.coroutines.CoroutineContext + +/** + * Bundles a [ComposeScene] with the [FrameRecomposer] that drives its frame + * loop. + * + * Compose 1.12 split the old `ComposeScene.render(canvas, nanoTime)` call + * (which used to own recomposition, layout, and draw) into three steps — + * `FrameRecomposer.performFrame` + `ComposeScene.measureAndLayout` + + * `ComposeScene.draw` — and replaced the scene factories' former + * `coroutineContext` / `invalidate` parameters with a [FrameRecomposer] plus + * separate `invalidateLayout` / `invalidateDraw` callbacks. (Compose Desktop's + * own AWT path wraps the same three calls in the module-private + * `SingleComposeSceneRenderingScope.render`, which isn't visible to external + * backends, so the Tao hosts drive them directly here.) This type keeps the + * scene and its recomposer together so the Tao hosts can create, render, and + * dispose a scene with a single object. + */ +@OptIn(InternalComposeUiApi::class) +internal class TaoSceneBundle( + val scene: ComposeScene, + val frameRecomposer: FrameRecomposer, +) : AutoCloseable { + /** + * Recomposes, lays out, and draws one frame into [canvas] — the drop-in + * replacement for the pre-1.12 `scene.render(canvas.asComposeCanvas(), nanoTime)`. + * [nanoTime] is fed to the recomposer's frame clock, so `withFrameNanos` + * animations advance on the timestamp the caller paces to. + */ + fun render( + canvas: Canvas, + nanoTime: Long, + ) { + frameRecomposer.performFrame(nanoTime) + scene.measureAndLayout() + scene.draw(canvas.asComposeCanvas()) + } + + override fun close() { + scene.close() + frameRecomposer.close() + } +} + +/** + * Creates a [CanvasLayersComposeScene] wired to a fresh [FrameRecomposer]. + * [requestFrame] is invoked whenever the scene needs to be repainted + * (recomposition, relayout, redraw, or a pending animation frame); callers + * funnel it into their coalescing frame scheduler. + */ +@OptIn(InternalComposeUiApi::class) +internal fun canvasLayersSceneBundle( + coroutineContext: CoroutineContext, + density: Density, + layoutDirection: LayoutDirection, + size: IntSize? = null, + platformContext: PlatformContext, + requestFrame: () -> Unit, +): TaoSceneBundle { + val frameRecomposer = FrameRecomposer(coroutineContext) { requestFrame() } + val scene = + CanvasLayersComposeScene( + frameRecomposer = frameRecomposer, + density = density, + layoutDirection = layoutDirection, + size = size, + platformContext = platformContext, + invalidateLayout = { requestFrame() }, + invalidateDraw = { requestFrame() }, + ) + return TaoSceneBundle(scene, frameRecomposer) +} + +/** + * Creates a [PlatformLayersComposeScene] wired to a fresh [FrameRecomposer]. + * See [canvasLayersSceneBundle]. + */ +@OptIn(InternalComposeUiApi::class) +internal fun platformLayersSceneBundle( + coroutineContext: CoroutineContext, + density: Density, + layoutDirection: LayoutDirection, + size: IntSize? = null, + composeSceneContext: ComposeSceneContext, + requestFrame: () -> Unit, +): TaoSceneBundle { + val frameRecomposer = FrameRecomposer(coroutineContext) { requestFrame() } + val scene = + PlatformLayersComposeScene( + frameRecomposer = frameRecomposer, + density = density, + layoutDirection = layoutDirection, + size = size, + composeSceneContext = composeSceneContext, + invalidateLayout = { requestFrame() }, + invalidateDraw = { requestFrame() }, + ) + return TaoSceneBundle(scene, frameRecomposer) +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneOuterLocalsBridgeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneOuterLocalsBridgeTest.kt index bd656af9d..d5d4bc4f9 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneOuterLocalsBridgeTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneOuterLocalsBridgeTest.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.scene.CanvasLayersComposeScene + import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset @@ -146,8 +146,9 @@ class TaoSceneOuterLocalsBridgeTest { */ private fun captureOuterLocals(layoutDirection: LayoutDirection = GlobalLayoutDirection): CompositionLocalContext { var captured: CompositionLocalContext? = null - val outerScene = - CanvasLayersComposeScene( + val outer = + canvasLayersSceneBundle( + coroutineContext = kotlin.coroutines.EmptyCoroutineContext, density = Density(1f), layoutDirection = GlobalLayoutDirection, size = IntSize(1, 1), @@ -159,16 +160,16 @@ private fun captureOuterLocals(layoutDirection: LayoutDirection = GlobalLayoutDi containerDpSize = DpSize(1.dp, 1.dp) } }, - invalidate = {}, + requestFrame = {}, ) try { - outerScene.setContent { + outer.scene.setContent { CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { captured = currentCompositionLocalContext } } } finally { - outerScene.close() + outer.close() } return requireNotNull(captured) { "outer scene never composed" } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 672254148..6eb3e8525 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -2,7 +2,6 @@ package dev.nucleusframework.window.tao.scene -import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.InternalComposeUiApi @@ -11,7 +10,6 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext -import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner @@ -46,7 +44,7 @@ import kotlin.coroutines.CoroutineContext * - the event-translation layer: [dispatchNativeKeyEvent], [taoKeyboardModifiers], * [TaoSyntheticMouseWheelEvent], mac/linux key tables; * - the CPU record path [recordSceneToPicture] the host uses for every frame; - * - the [CanvasLayersComposeScene] configuration mirrored from + * - the `CanvasLayersComposeScene` configuration mirrored from * `TaoComposeSceneHost.attach()` (same scene type, same clock/dispatcher * context shape). * @@ -56,7 +54,7 @@ import kotlin.coroutines.CoroutineContext * guards, so a regression in those contracts fails here first. * * Time is fully synthetic: [frame] advances a virtual clock, pumps the - * single-threaded dispatcher, delivers [BroadcastFrameClock] frames and + * single-threaded dispatcher, delivers frame-clock ticks and * records the scene through [recordSceneToPicture] — one call, one frame, * bit-for-bit reproducible. */ @@ -191,7 +189,6 @@ internal class TaoSceneTestScope( density: Float, ) { private val dispatcher = QueueDispatcher() - private val frameClock = BroadcastFrameClock() private var timeNanos = 0L private var invalidated = false @@ -229,16 +226,18 @@ internal class TaoSceneTestScope( } } - val scene: ComposeScene = - CanvasLayersComposeScene( + private val sceneBundle: TaoSceneBundle = + canvasLayersSceneBundle( + coroutineContext = dispatcher, density = Density(density), layoutDirection = GlobalLayoutDirection, size = IntSize(width, height), - coroutineContext = dispatcher + frameClock, platformContext = platformContext, - invalidate = { invalidated = true }, + requestFrame = { invalidated = true }, ) + val scene: ComposeScene get() = sceneBundle.scene + // ── Host-mirrored pointer state (same guards as TaoComposeSceneHost) ──── private var lastPointerX = 0f private var lastPointerY = 0f @@ -250,7 +249,7 @@ internal class TaoSceneTestScope( private set fun setContent(content: @Composable () -> Unit) { - scene.setContent(content) + scene.setContent(content = content) frame() } @@ -277,9 +276,12 @@ internal class TaoSceneTestScope( // withFrameNanos) marks the scene dirty for frameUntilIdle. invalidated = false pumpUntilIdle() - frameClock.sendFrame(timeNanos) - pumpUntilIdle() - return recordSceneToPicture(scene, width, height, timeNanos).also { lastPicture = it } + // The frame clock is ticked inside `recordSceneToPicture` (Compose 1.12 + // drives it through `FrameRecomposer.performFrame`, which flushes its own + // dispatchers around the tick), so the recompose triggered by this + // frame's `withFrameNanos` continuations is part of the recorded picture + // — same guarantee the explicit sendFrame + pump used to give. + return recordSceneToPicture(sceneBundle, width, height, timeNanos).also { lastPicture = it } } /** @@ -553,7 +555,7 @@ internal class TaoSceneTestScope( } fun close() { - scene.close() + sceneBundle.close() dispatcher.pump() } diff --git a/examples/cmp-demo/build.gradle.kts b/examples/cmp-demo/build.gradle.kts index 3367608f6..4a904ae3e 100644 --- a/examples/cmp-demo/build.gradle.kts +++ b/examples/cmp-demo/build.gradle.kts @@ -50,11 +50,11 @@ kotlin { android { namespace = "com.example.samplecmp" - compileSdk = 35 + compileSdk = 37 defaultConfig { applicationId = "com.example.samplecmp" minSdk = 24 - targetSdk = 35 + targetSdk = 37 versionCode = 1 versionName = "1.0" } diff --git a/examples/cmp-demo/src/commonMain/kotlin/com/example/samplecmp/App.kt b/examples/cmp-demo/src/commonMain/kotlin/com/example/samplecmp/App.kt index 78b60a443..b76944e0c 100644 --- a/examples/cmp-demo/src/commonMain/kotlin/com/example/samplecmp/App.kt +++ b/examples/cmp-demo/src/commonMain/kotlin/com/example/samplecmp/App.kt @@ -8,7 +8,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import org.jetbrains.skia.Surface @Composable fun App() { diff --git a/gradle.properties b/gradle.properties index eee6e52e4..af1a34652 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,3 +7,10 @@ kotlin.code.style=official # Android android.useAndroidX=true +# AGP 9 rejects `com.android.application` alongside the Kotlin Multiplatform plugin and +# steers KMP modules to `com.android.kotlin.multiplatform.library` — which is a *library* +# plugin, so examples:cmp-demo (a KMP module that also builds a real Android app) can't +# adopt it without being split into a separate :androidApp module. These two flags are +# AGP's documented escape hatch for exactly that case. +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 399ef20f6..a397a00b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] -agp = "8.13.2" +agp = "9.1.1" asm = "9.10.1" bcv = "0.18.1" awsSdk = "2.47.4" batik = "1.19" coilVersion = "3.5.0" -compose = "1.11.1" +compose = "1.12.0-rc01" coroutines = "1.11.0" detekt = "2.0.0-alpha.5" downloadTask = "5.7.0" @@ -14,8 +14,8 @@ graalvmNative = "1.1.3" # Must match the hot-reload version bundled by the Compose Gradle plugin (which auto-applies # hot-reload to every Compose module): TaoHotReloadBridgeImpl compiles against these artifacts # but the runtime ones come from the agent, and the WindowsState API is not binary-stable -# across releases. Compose 1.11.x bundles 1.1.1 — bump both together. -hotReload = "1.1.1" +# across releases. Compose 1.12.x bundles 1.2.0 — bump both together. +hotReload = "1.2.0" icons = "262.9437.16" jbrApi = "1.10.1" jewel = "0.39.0-262.9437.21" @@ -30,7 +30,7 @@ lighthouse = "2.3.1" # Only used by :examples:tao-native-test, as the native-image regression fixture for the # SLF4J/Logback build-time-initialization clash (issue #443). logback = "1.5.34" -material3 = "1.9.0" +material3 = "1.12.0-alpha03" materialkolor = "4.1.1" navigation3 = "1.1.1" materialIcons = "1.7.3" diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt index 9fab6c678..627f1cf69 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt @@ -1,31 +1,31 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package dev.nucleusframework.application import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.LocalSystemTheme -import androidx.compose.ui.SystemTheme import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import org.jetbrains.skiko.SystemTheme /** * Feeds Compose's [androidx.compose.foundation.isSystemInDarkTheme] from * Nucleus's reactive OS detector. * - * On Compose Desktop 1.11, `isSystemInDarkTheme()` only reads - * [LocalSystemTheme], whose default is a non-reactive Skiko snapshot. Providing - * the local from [isSystemInDarkMode] makes every official call site (and any - * library that uses it) track OS dark-mode changes the same way Nucleus does. + * Compose 1.12 made [LocalSystemTheme] internal and typed it as Skiko's + * [SystemTheme]. Official `isSystemInDarkTheme()` now polls the OS about once + * a second; providing the local from [isSystemInDarkMode] keeps every call + * site on Nucleus's live detector instead of that poll. * * The value is computed *outside* the provider, so the detector never reads the * local it is about to set (preview path of [isSystemInDarkMode] falls back to * `isSystemInDarkTheme()`). */ -@OptIn(InternalComposeUiApi::class) @Composable internal fun ProvideNucleusSystemTheme(content: @Composable () -> Unit) { val isDark = isSystemInDarkMode() CompositionLocalProvider( - LocalSystemTheme provides if (isDark) SystemTheme.Dark else SystemTheme.Light, + LocalSystemTheme provides if (isDark) SystemTheme.DARK else SystemTheme.LIGHT, content = content, ) } diff --git a/plugin-build/build.gradle.kts b/plugin-build/build.gradle.kts index 9b9d050da..0f1cffb54 100644 --- a/plugin-build/build.gradle.kts +++ b/plugin-build/build.gradle.kts @@ -71,7 +71,7 @@ lighthouse { } tasks.withType().configureEach { - jvmTarget.set("11") + jvmTarget.set("17") reports { html.required.set(true) html.outputLocation.set(file("build/reports/detekt.html")) diff --git a/plugin-build/plugin/build.gradle.kts b/plugin-build/plugin/build.gradle.kts index 5864e44a6..b75dff91f 100644 --- a/plugin-build/plugin/build.gradle.kts +++ b/plugin-build/plugin/build.gradle.kts @@ -62,9 +62,14 @@ lighthouse { enableUnusedDependencyCheck.set(false) } +// Java 17 is the floor imposed by AGP 9.x, which this plugin compiles against +// (compileOnly) — AGP 9 artifacts are built for 17 and can't be resolved by an +// 11-targeted consumer. Consequence for users: the plugin now needs a Gradle +// daemon on JVM 17+ (which Gradle 9 mandates anyway; Gradle 8 users on JVM 11 +// are no longer supported). java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } // === Sandbox runtime shim jar + embedding === @@ -97,7 +102,7 @@ tasks.named("processResources") { kotlin { compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) + jvmTarget.set(JvmTarget.JVM_17) freeCompilerArgs.add("-opt-in=dev.nucleusframework.ExperimentalNucleusLibrary") } }