Skip to content

Commit cb0fd13

Browse files
authored
Merge pull request #362 from NucleusFramework/feat/tao-hot-reload
feat(decorated-window-tao): integrate Compose Hot Reload
2 parents 11bc1a3 + 18990d1 commit cb0fd13

7 files changed

Lines changed: 317 additions & 30 deletions

File tree

decorated-window-tao/build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ dependencies {
1919
api(project(":decorated-window-core"))
2020
implementation(project(":core-runtime"))
2121
implementation(libs.compose.desktop.common)
22+
// Compose Hot Reload interop (TaoHotReloadBridge). compileOnly: these
23+
// artifacts are only referenced when running under the hot-reload agent,
24+
// which puts them on the runtime classpath itself (the plugin adds
25+
// runtime-jvm — which brings devtools-api — and the agent jar brings the
26+
// `agent`/`core`/`orchestration` classes). Used only by `trackWindow`
27+
// (WindowsState / orchestration publishing), not by any wrapping.
28+
compileOnly(libs.hot.reload.agent)
29+
compileOnly(libs.hot.reload.core)
30+
compileOnly(libs.hot.reload.orchestration)
31+
compileOnly(libs.hot.reload.devtools.api)
2232
testImplementation(kotlin("test"))
2333
// Skiko native runtime for the opt-in real-window smoke test
2434
testImplementation(compose.desktop.currentOs)

decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,21 @@ internal fun ApplicationScope.openDecoratedWindow(
186186
skipTaskbar = hiddenFromDock,
187187
)
188188

189+
// Compose Hot Reload: the agent only auto-wraps AWT `ComposeWindow`/
190+
// `ComposeDialog` `setContent` — Tao owns its own windows, so we wrap the
191+
// scene content in `DevelopmentEntryPoint` ourselves, and publish this
192+
// window's geometry into the orchestration `WindowsState` so the dev-tools
193+
// sidecar can follow it. Both no-op when not running under the hot-reload
194+
// agent. See [TaoHotReloadIntegration].
195+
val hotReloadContent: @Composable TaoDecoratedWindowScope.() -> Unit = {
196+
TaoHotReloadIntegration.wrapContent { content() }
197+
}
198+
// Popups (popupFor != null) are transient overlays; the sidecar tracks
199+
// only real windows/dialogs, matching Compose Hot Reload's AWT tracker.
200+
if (popupFor == null) {
201+
TaoHotReloadIntegration.trackWindow(window, title, alwaysOnTop)
202+
}
203+
189204
if (Platform.Current == Platform.Windows) {
190205
return openDecoratedWindowWindows(
191206
window,
@@ -203,7 +218,7 @@ internal fun ApplicationScope.openDecoratedWindow(
203218
onKeyEvent,
204219
initialCompositionLocalContext,
205220
nativePopupLayers,
206-
content,
221+
hotReloadContent,
207222
)
208223
}
209224

@@ -225,7 +240,7 @@ internal fun ApplicationScope.openDecoratedWindow(
225240
onKeyEvent,
226241
initialCompositionLocalContext,
227242
nativePopupLayers,
228-
content,
243+
hotReloadContent,
229244
)
230245
}
231246

@@ -337,7 +352,7 @@ internal fun ApplicationScope.openDecoratedWindow(
337352
}
338353
}
339354
Column(modifier = Modifier.fillMaxSize()) {
340-
scopeFactory().content()
355+
scopeFactory().hotReloadContent()
341356
}
342357
}
343358
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package dev.nucleusframework.window.tao
2+
3+
import kotlinx.coroutines.channels.Channel
4+
import kotlinx.coroutines.channels.trySendBlocking
5+
import kotlinx.coroutines.flow.conflate
6+
import kotlinx.coroutines.flow.consumeAsFlow
7+
import org.jetbrains.compose.devtools.api.WindowsState
8+
import org.jetbrains.compose.reload.agent.orchestration
9+
import org.jetbrains.compose.reload.agent.sendAsync
10+
import org.jetbrains.compose.reload.core.WindowId
11+
import org.jetbrains.compose.reload.orchestration.OrchestrationMessage
12+
import kotlin.math.roundToInt
13+
14+
/**
15+
* Concrete [TaoHotReloadBridge], loaded reflectively by [TaoHotReloadIntegration]
16+
* only when Compose Hot Reload is active (`compose.reload.isActive == "true"`),
17+
* which is also when the hot-reload artifacts land on the runtime classpath.
18+
*
19+
* Mirrors `org.jetbrains.compose.reload.jvm.startWindowManager` (the AWT-based
20+
* tracker in `hot-reload-runtime-jvm`), but driven by Tao window callbacks
21+
* instead of `java.awt.Window` listeners — the Tao backend has no AWT windows
22+
* for the agent's auto-instrumentation to attach to.
23+
*
24+
* Coordinates are converted from Tao's **physical** pixels to the **logical**
25+
* pixels the dev-tools sidecar expects (matching AWT's `window.x/width`, which
26+
* `startWindowManager` sends verbatim). Fractional HiDPI scales may drift a
27+
* few pixels; integer scales are exact.
28+
*
29+
* Version-skew safety: this class compiles against the catalog's hot-reload
30+
* version, but the runtime classes come from whatever agent the user's Compose
31+
* plugin launched — and the orchestration API is not binary-stable across
32+
* releases (e.g. `WindowsState.WindowState` gained a `title` parameter in
33+
* 1.2.0 and dropped the old constructor). Every entry point is guarded: the
34+
* first [LinkageError] (or any other failure) permanently disables tracking
35+
* for that window, so the sidecar merely stops following it — the app itself
36+
* must never be taken down by dev tooling.
37+
*/
38+
internal class TaoHotReloadBridgeImpl : TaoHotReloadBridge {
39+
40+
override fun trackWindow(window: TaoWindow, title: String?, alwaysOnTop: Boolean) {
41+
// [title] is unused: WindowsState.WindowState has no title field in the
42+
// hot-reload version bundled by Compose 1.11.x. Kept in the bridge
43+
// surface so the call sites don't change when a newer API gains one.
44+
val windowId = WindowId.create()
45+
// Conflated channel: coalesce bursts of move/resize events (GTK fires
46+
// one per pixel of a drag) into a single WindowsState update, exactly
47+
// like the AWT tracker's `Channel.CONFLATED` + `conflate()`.
48+
val windowState = Channel<WindowsState.WindowState?>(Channel.CONFLATED)
49+
50+
// One-shot kill switch: flipped on the first failure inside a Tao
51+
// callback (see class KDoc). Closing the channel also ends the pump.
52+
var broken = false
53+
54+
fun guarded(block: () -> Unit) {
55+
if (broken) return
56+
try {
57+
block()
58+
} catch (t: Throwable) {
59+
broken = true
60+
windowState.close()
61+
}
62+
}
63+
64+
// Pump channel → orchestration WindowsState. Runs on the orchestration
65+
// task's dispatcher (off the Tao main thread). `null` removes the
66+
// window (minimize/hide/destroy) — matches AWT's `broadcastGone`.
67+
orchestration.subtask {
68+
windowState.consumeAsFlow().conflate().collect { state ->
69+
orchestration.update(WindowsState) { current ->
70+
val windows = if (state == null) {
71+
current.windows - windowId
72+
} else {
73+
current.windows + (windowId to state)
74+
}
75+
WindowsState(windows)
76+
}
77+
}
78+
}
79+
80+
fun toLogical(physical: Int): Int = (physical / window.scaleFactor).roundToInt()
81+
82+
/** Reads the live outer rect and pushes a conflated state update. */
83+
fun broadcastActiveState() {
84+
val bounds = window.outerBoundsPx() ?: return
85+
// [x, y, width, height] physical px → logical.
86+
val x = toLogical(bounds[0].toInt())
87+
val y = toLogical(bounds[1].toInt())
88+
val w = toLogical(bounds[2].toInt())
89+
val h = toLogical(bounds[3].toInt())
90+
if (w <= 0 || h <= 0) return
91+
windowState.trySendBlocking(
92+
WindowsState.WindowState(
93+
x = x, y = y, width = w, height = h,
94+
isAlwaysOnTop = alwaysOnTop,
95+
),
96+
)
97+
OrchestrationMessage.ApplicationWindowPositioned(
98+
windowId, x, y, w, h, isAlwaysOnTop = alwaysOnTop,
99+
).sendAsync()
100+
}
101+
102+
fun broadcastGone() {
103+
windowState.trySendBlocking(null)
104+
OrchestrationMessage.ApplicationWindowGone(windowId).sendAsync()
105+
}
106+
107+
// Initial state: outerBoundsPx is null until the window is mapped, so
108+
// the first meaningful broadcast happens via onResized (fires right
109+
// after onWindowReady with the mapped size). Still try once in case
110+
// geometry is already available.
111+
guarded { broadcastActiveState() }
112+
113+
window.onMoved { _, _ -> guarded { broadcastActiveState() } }
114+
window.onResized { _, _ -> guarded { broadcastActiveState() } }
115+
window.onFocusChanged { focused ->
116+
if (focused) {
117+
guarded {
118+
// Sidecar brings itself toFront() on this.
119+
OrchestrationMessage.ApplicationWindowGainedFocus(windowId).sendAsync()
120+
broadcastActiveState()
121+
}
122+
}
123+
}
124+
window.onMinimizedChanged { minimized ->
125+
guarded { if (minimized) broadcastGone() else broadcastActiveState() }
126+
}
127+
window.onDestroyed { guarded { broadcastGone() } }
128+
}
129+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package dev.nucleusframework.window.tao
2+
3+
import androidx.compose.runtime.Composable
4+
5+
/**
6+
* Bridges the Tao windowing backend to JetBrains Compose Hot Reload.
7+
*
8+
* Compose Hot Reload's runtime assumes an AWT windowing model: a window manager
9+
* (`org.jetbrains.compose.reload.jvm.startWindowManager`) installs
10+
* `ComponentListener`s on each `java.awt.Window` to publish its geometry into
11+
* the orchestration `WindowsState` — which the separate dev-tools process uses
12+
* to position its sidecar window next to the app's. The Tao backend owns its
13+
* own windows (Tao/GTK/Win32/AppKit via JNI) and has no `java.awt.Window`, so
14+
* nobody publishes geometry and the sidecar never appears.
15+
*
16+
* [trackWindow] fixes that by feeding Tao window callbacks into the same
17+
* `WindowsState` orchestration state, so the dev-tools sidecar follows the Tao
18+
* window exactly like it follows an AWT `ComposeWindow`.
19+
*
20+
* This object stays on the hot path (called from [openDecoratedWindow]) so it
21+
* must never reference any hot-reload class directly: those live in artifacts
22+
* (`hot-reload-agent`, `-core`, `-orchestration`, `-devtools-api`) that are
23+
* only on the runtime classpath under the hot-reload agent. When hot reload is
24+
* inactive (`compose.reload.isActive != "true"`, the property the agent sets),
25+
* [bridge] is `null` and every method is a no-op / pass-through. When active,
26+
* the agent has already placed those artifacts on the classpath, so we load
27+
* [TaoHotReloadBridgeImpl] reflectively. Mirrors [LifecycleMainDispatcherPriming].
28+
*/
29+
internal object TaoHotReloadIntegration {
30+
private val active: Boolean = System.getProperty("compose.reload.isActive") == "true"
31+
32+
private val bridge: TaoHotReloadBridge? =
33+
if (active) {
34+
runCatching {
35+
Class.forName("dev.nucleusframework.window.tao.TaoHotReloadBridgeImpl")
36+
.getDeclaredConstructor().newInstance() as TaoHotReloadBridge
37+
}.getOrNull()
38+
} else {
39+
null
40+
}
41+
42+
/** True when running under the Compose Hot Reload agent and the bridge loaded. */
43+
val isActive: Boolean get() = bridge != null
44+
45+
/**
46+
* Hook for wrapping scene content in a hot-reload entry point. Currently a
47+
* pass-through: see the class KDoc for why we don't wrap in
48+
* `DevelopmentEntryPoint` here. Kept as an indirection at the
49+
* [openDecoratedWindow] call site so a wrapper can be introduced later
50+
* without touching every platform path.
51+
*/
52+
@Composable
53+
fun wrapContent(content: @Composable () -> Unit): Unit = content()
54+
55+
/**
56+
* Publishes [window]'s geometry into the hot-reload orchestration
57+
* `WindowsState` so the dev-tools sidecar can follow it. No-op when hot
58+
* reload is inactive. [title] / [alwaysOnTop] are captured from the
59+
* `openDecoratedWindow` call site (TaoWindow exposes no getters for them).
60+
*
61+
* Failure-proof: the runtime hot-reload classes come from the agent and can
62+
* be a different (binary-incompatible) version than the one the bridge was
63+
* compiled against — a broken bridge must degrade to "no sidecar tracking",
64+
* never crash the app. See [TaoHotReloadBridgeImpl].
65+
*/
66+
fun trackWindow(window: TaoWindow, title: String?, alwaysOnTop: Boolean) {
67+
runCatching { bridge?.trackWindow(window, title, alwaysOnTop) }
68+
}
69+
}
70+
71+
/**
72+
* Hot-reload bridge surface. Implemented by [TaoHotReloadBridgeImpl], loaded
73+
* reflectively only when hot reload is active (and thus the hot-reload
74+
* artifacts are on the classpath). Kept free of hot-reload type references so
75+
* it can be loaded unconditionally.
76+
*/
77+
internal interface TaoHotReloadBridge {
78+
fun trackWindow(window: TaoWindow, title: String?, alwaysOnTop: Boolean)
79+
}

examples/tao-demo/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ plugins {
77
kotlin("jvm")
88
alias(libs.plugins.kotlinComposePlugin)
99
alias(libs.plugins.jetbrainsCompose)
10+
alias(libs.plugins.composeHotReload)
1011
id("dev.nucleusframework")
1112
}
1213

gradle/libs.versions.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ detekt = "2.0.0-alpha.5"
1010
downloadTask = "5.7.0"
1111
filekit = "0.14.2"
1212
graalvmNative = "1.1.3"
13+
# Must match the hot-reload version bundled by the Compose Gradle plugin (which auto-applies
14+
# hot-reload to every Compose module): TaoHotReloadBridgeImpl compiles against these artifacts
15+
# but the runtime ones come from the agent, and the WindowsState API is not binary-stable
16+
# across releases. Compose 1.11.x bundles 1.1.1 — bump both together.
17+
hotReload = "1.1.1"
1318
icons = "261.23567.174"
1419
jbrApi = "1.10.1"
1520
jewel = "0.37.0-262.4852.51"
@@ -43,13 +48,18 @@ vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "v
4348
kotlinComposePlugin = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin"}
4449
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose"}
4550
graalvmNative = { id = "org.graalvm.buildtools.native", version.ref = "graalvmNative" }
51+
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "hotReload" }
4652
lighthouse = { id = "io.github.dev-vikas-soni.lighthouse", version.ref = "lighthouse" }
4753
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
4854
androidApplication = { id = "com.android.application", version.ref = "agp" }
4955
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
5056

5157
[libraries]
5258
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" }
59+
hot-reload-agent = { module = "org.jetbrains.compose.hot-reload:hot-reload-agent", version.ref = "hotReload" }
60+
hot-reload-core = { module = "org.jetbrains.compose.hot-reload:hot-reload-core", version.ref = "hotReload" }
61+
hot-reload-orchestration = { module = "org.jetbrains.compose.hot-reload:hot-reload-orchestration", version.ref = "hotReload" }
62+
hot-reload-devtools-api = { module = "org.jetbrains.compose.hot-reload:hot-reload-devtools-api", version.ref = "hotReload" }
5363
intellij-icons = { module = "com.jetbrains.intellij.platform:icons", version.ref = "icons" }
5464
junit = "junit:junit:4.13.2"
5565
zstd-kmp-jvm = { module = "com.squareup.zstd:zstd-kmp-jvm", version.ref = "zstdKmp" }

0 commit comments

Comments
 (0)