diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 27cd7a3f..35eb1ddf 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -2,7 +2,5 @@
-
-
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 0c24226b..075acc69 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -47,9 +47,10 @@ kotlin {
implementation(compose.ui)
implementation(compose.components.resources)
implementation(libs.kotlinx.coroutines.core)
- implementation(libs.kotlinx.coroutines.swing)
implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.darkmode.detector)
+ api(libs.nucleus.application)
+ implementation(libs.nucleus.decorated.window.tao)
}
jvmTest.dependencies {
implementation(kotlin("test"))
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
index 9d6f62f2..16f971c4 100644
--- a/demo/build.gradle.kts
+++ b/demo/build.gradle.kts
@@ -24,6 +24,8 @@ kotlin {
implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.darkmode.detector)
implementation(libs.nucleus.graalvm.runtime)
+ implementation(libs.nucleus.decorated.window.tao)
+ implementation(libs.nucleus.decorated.window.material3)
}
}
}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt
index 59d3f1f2..b92066ab 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -11,26 +12,27 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
-import androidx.compose.ui.window.application
-import androidx.compose.ui.window.rememberWindowState
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
import dev.nucleusframework.composenativetray.utils.getTrayWindowPosition
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import org.jetbrains.compose.resources.painterResource
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = false
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
val logTag = "NativeTray"
-
+
println("$logTag: TrayPosition: ${getTrayPosition()}")
var isWindowVisible by remember { mutableStateOf(true) }
@@ -40,16 +42,6 @@ fun main() = application {
var notificationsEnabled by remember { mutableStateOf(false) }
var initialChecked by remember { mutableStateOf(true) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
-
-
// Always create the Tray composable, but make it conditional on visibility
// This ensures it's recomposed when alwaysShowTray changes
val showTray = alwaysShowTray || !isWindowVisible
@@ -146,26 +138,29 @@ fun main() = application {
val windowHeight = 600
val windowPosition = getTrayWindowPosition(windowWidth, windowHeight)
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ state = WindowState(
+ width = windowWidth.dp,
+ height = windowHeight.dp,
+ position = windowPosition
+ ),
+ title = "Compose Desktop Application with Two Screens",
+ visible = isWindowVisible,
+ icon = painterResource(Res.drawable.icon) // Optional: Set window icon
+ ) {
+ TitleBar { Text("Compose Desktop Application with Two Screens") }
+ App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- state = WindowState(
- width = windowWidth.dp,
- height = windowHeight.dp,
- position = windowPosition
- ),
- title = "Compose Desktop Application with Two Screens",
- visible = isWindowVisible,
- icon = painterResource(Res.drawable.icon) // Optional: Set window icon
- ) {
- App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
-}
\ No newline at end of file
+}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoComposableMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoComposableMenu.kt
index 8c6a1656..46b3d6fb 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoComposableMenu.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoComposableMenu.kt
@@ -1,13 +1,17 @@
package dev.nucleusframework.composenativetray.demo
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
-import dev.nucleusframework.core.runtime.SingleInstanceManager
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import composenativetray.demo.generated.resources.icon2
@@ -15,20 +19,12 @@ import org.jetbrains.compose.resources.painterResource
/**
* Showcases the @Composable menu DSL. No need to hoist `painterResource(...)` above
- * `application { … }` — every menu/submenu lambda is composable.
+ * `nucleusApplication { … }` — every menu/submenu lambda is composable.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
var isWindowVisible by remember { mutableStateOf(true) }
var enableHeavyMode by remember { mutableStateOf(false) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
-
Tray(
icon = painterResource(Res.drawable.icon),
tooltip = "Composable Menu Demo",
@@ -68,12 +64,20 @@ fun main() = application {
},
)
- Window(
- onCloseRequest = { isWindowVisible = false },
- title = "Composable Menu Demo",
- visible = isWindowVisible,
- icon = painterResource(Res.drawable.icon),
- ) {
- window.toFront()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = { isWindowVisible = false },
+ title = "Composable Menu Demo",
+ visible = isWindowVisible,
+ icon = painterResource(Res.drawable.icon),
+ ) {
+ TitleBar { Text("Composable Menu Demo") }
+ val window = nucleusWindow
+ LaunchedEffect(isWindowVisible) {
+ if (isWindowVisible) {
+ window.toFront()
+ }
+ }
+ }
}
}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoToggleEmptyMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoToggleEmptyMenu.kt
index 6c3d3a19..62c0a47a 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoToggleEmptyMenu.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoToggleEmptyMenu.kt
@@ -8,9 +8,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
@@ -27,7 +30,7 @@ import org.jetbrains.compose.resources.painterResource
* the presence of a single menu item. When the flag is false, the menuContent
* is intentionally left empty.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
// Enable logging to help diagnose behavior on Linux
allowComposeNativeTrayLogging = false
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -61,7 +64,13 @@ fun main() = application {
}
- Window( onCloseRequest = ::exitApplication) {
- Text("Compose Native Tray Demo")
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = ::exitApplication,
+ title = "Compose Native Tray Demo"
+ ) {
+ TitleBar { Text("Compose Native Tray Demo") }
+ Text("Compose Native Tray Demo")
+ }
}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoTransparentWindow.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoTransparentWindow.kt
index 30f63fe4..ba4b4c2e 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoTransparentWindow.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoTransparentWindow.kt
@@ -23,9 +23,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import org.jetbrains.compose.resources.painterResource
@@ -33,14 +35,15 @@ import org.jetbrains.compose.resources.painterResource
/**
* DemoTransparentWindow
*
- * A minimal example showing a simple system tray with a transparent, undecorated window.
+ * A minimal example showing a simple system tray with an undecorated window.
* Left click (primaryAction) on the tray icon shows the window. Use the tray menu to
- * hide or exit. The window content itself is a rounded card floating on a transparent background.
+ * hide or exit. The window content itself is a rounded card. Note: per-pixel
+ * transparency is not supported on the Tao backend.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
var isWindowVisible by remember { mutableStateOf(true) }
- // Simple tray with primary action to show the transparent window
+ // Simple tray with primary action to show the window
Tray(
icon = painterResource(Res.drawable.icon),
tooltip = "Transparent Window Demo",
@@ -55,45 +58,48 @@ fun main() = application {
}
}
- Window(
- onCloseRequest = { isWindowVisible = false },
- visible = isWindowVisible,
- undecorated = true,
- transparent = true,
- alwaysOnTop = false,
- resizable = false,
- title = "Transparent Window Demo",
- icon = painterResource(Res.drawable.icon)
- ) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(24.dp)
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ // Note: per-pixel transparency is not supported on the Tao backend,
+ // so the window is only undecorated here.
+ DecoratedWindow(
+ onCloseRequest = { isWindowVisible = false },
+ visible = isWindowVisible,
+ undecorated = true,
+ alwaysOnTop = false,
+ resizable = false,
+ title = "Transparent Window Demo",
+ icon = painterResource(Res.drawable.icon)
) {
- val shape = RoundedCornerShape(16.dp)
- Column(
+ Box(
modifier = Modifier
- .align(Alignment.Center)
- .shadow(24.dp, shape)
- .clip(shape)
- .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
- .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
- .padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ .fillMaxSize()
+ .padding(24.dp)
) {
- Text(
- text = "Transparent window",
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.headlineSmall
- )
- Text(
- text = "This window is undecorated and the background is transparent.",
- color = MaterialTheme.colorScheme.onSurface
- )
- Spacer(Modifier.height(8.dp))
- Button(onClick = { isWindowVisible = false }) {
- Text("Hide")
+ val shape = RoundedCornerShape(16.dp)
+ Column(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .shadow(24.dp, shape)
+ .clip(shape)
+ .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Transparent window",
+ color = MaterialTheme.colorScheme.onSurface,
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Text(
+ text = "This window is undecorated. Per-pixel transparency is not supported on the Tao backend.",
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(Modifier.height(8.dp))
+ Button(onClick = { isWindowVisible = false }) {
+ Text("Hide")
+ }
}
}
}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt
index 1bb7916a..81cff4e4 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt
@@ -13,20 +13,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import org.jetbrains.compose.resources.painterResource
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -49,15 +50,6 @@ fun main() = application {
var darkModeEnabled by remember { mutableStateOf(false) }
var autoStartEnabled by remember { mutableStateOf(true) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
-
// Always create the Tray composable, but make it conditional on visibility
val showTray = alwaysShowTray || !isWindowVisible
@@ -208,23 +200,26 @@ fun main() = application {
}
}
- Window(
- transparent = true,
- undecorated = true,
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ // Note: per-pixel transparency is not supported on the Tao backend,
+ // so the window is only undecorated here.
+ DecoratedWindow(
+ undecorated = true,
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Compose Desktop Application with Dynamic Tray Menu",
+ visible = isWindowVisible,
+ icon = org.jetbrains.compose.resources.painterResource(Res.drawable.icon)
+ ) {
+ App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "Compose Desktop Application with Dynamic Tray Menu",
- visible = isWindowVisible,
- icon = org.jetbrains.compose.resources.painterResource(Res.drawable.icon)
- ) {
- App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithDrawableResources.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithDrawableResources.kt
index 2ddfdc72..4df3faaf 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithDrawableResources.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithDrawableResources.kt
@@ -1,16 +1,20 @@
package dev.nucleusframework.composenativetray.demo
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import composenativetray.demo.generated.resources.icon2
@@ -20,7 +24,7 @@ import composenativetray.demo.generated.resources.icon2
* and for context menu icons, e.g.:
* Tray(icon = Res.drawable.icon) { Item(label = "Settings", icon = Res.drawable.icon2) }
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -30,15 +34,6 @@ fun main() = application {
var alwaysShowTray by remember { mutableStateOf(true) }
var hideOnClose by remember { mutableStateOf(true) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
-
val showTray = alwaysShowTray || !isWindowVisible
if (showTray) {
@@ -95,26 +90,34 @@ fun main() = application {
}
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Demo with DrawableResource icons",
+ visible = isWindowVisible,
+ // Note: Window icon can still use painterResource if desired; omitted here for simplicity
+ ) {
+ TitleBar { Text("Demo with DrawableResource icons") }
+ val window = nucleusWindow
+ LaunchedEffect(isWindowVisible) {
+ if (isWindowVisible) {
+ window.toFront()
+ }
+ }
+ App(
+ textVisible = true,
+ alwaysShowTray = alwaysShowTray,
+ hideOnClose = hideOnClose
+ ) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "Demo with DrawableResource icons",
- visible = isWindowVisible,
- // Note: Window icon can still use painterResource if desired; omitted here for simplicity
- ) {
- window.toFront()
- App(
- textVisible = true,
- alwaysShowTray = alwaysShowTray,
- hideOnClose = hideOnClose
- ) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt
index 10d454d0..33a7509a 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt
@@ -1,19 +1,22 @@
package dev.nucleusframework.composenativetray.demo
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.demo.svg.AcademicCap
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import org.jetbrains.compose.resources.painterResource
@@ -22,7 +25,7 @@ import org.jetbrains.compose.resources.painterResource
* Demo application that showcases the use of the ImageVector API for tray icons.
* This demo uses the AcademicCap vector as the tray icon.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = false
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -37,14 +40,7 @@ fun main() = application {
// Tint color state for the icon
var iconTint by remember { mutableStateOf(null) } // null means use default (white/black based on theme)
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Single-instance handling is now managed by nucleusApplication (disabled here)
// Always create the Tray composable, but make it conditional on visibility
val showTray = alwaysShowTray || !isWindowVisible
@@ -127,26 +123,29 @@ fun main() = application {
}
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "ImageVector Tray Demo - AcademicCap",
+ visible = isWindowVisible,
+ icon = painterResource(Res.drawable.icon)
+ ) {
+ TitleBar { Text("ImageVector Tray Demo - AcademicCap") }
+ nucleusWindow.toFront()
+ App(
+ textVisible = true,
+ alwaysShowTray = alwaysShowTray,
+ hideOnClose = hideOnClose
+ ) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "ImageVector Tray Demo - AcademicCap",
- visible = isWindowVisible,
- icon = painterResource(Res.drawable.icon)
- ) {
- window.toFront()
- App(
- textVisible = true,
- alwaysShowTray = alwaysShowTray,
- hideOnClose = hideOnClose
- ) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt
index 7e470284..37aa40ce 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt
@@ -1,17 +1,20 @@
package dev.nucleusframework.composenativetray.demo
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import composenativetray.demo.generated.resources.icon2
@@ -21,7 +24,7 @@ import org.jetbrains.compose.resources.painterResource
* Demo application that showcases the use of the Painter API for tray icons.
* This demo uses Res.drawable.icon and Res.drawable.icon2 resources with dynamic switching.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -36,14 +39,7 @@ fun main() = application {
// Icon state for switching between two different icons
var currentIcon by remember { mutableStateOf(Res.drawable.icon) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Single-instance handling is now managed by nucleusApplication (disabled here)
// Always create the Tray composable, but make it conditional on visibility
val showTray = alwaysShowTray || !isWindowVisible
@@ -112,26 +108,29 @@ fun main() = application {
}
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Painter Tray Demo - Resource Icons",
+ visible = isWindowVisible,
+ icon = painterResource(Res.drawable.icon)
+ ) {
+ TitleBar { Text("Painter Tray Demo - Resource Icons") }
+ nucleusWindow.toFront()
+ App(
+ textVisible = true,
+ alwaysShowTray = alwaysShowTray,
+ hideOnClose = hideOnClose
+ ) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "Painter Tray Demo - Resource Icons",
- visible = isWindowVisible,
- icon = painterResource(Res.drawable.icon)
- ) {
- window.toFront()
- App(
- textVisible = true,
- alwaysShowTray = alwaysShowTray,
- hideOnClose = hideOnClose
- ) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt
index c87b4d92..d597f071 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt
@@ -1,19 +1,22 @@
package dev.nucleusframework.composenativetray.demo
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.demo.svg.AcademicCap
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import composenativetray.demo.generated.resources.icon2
@@ -25,7 +28,7 @@ import org.jetbrains.compose.resources.painterResource
* - A Painter resource for Windows
* - An ImageVector for macOS and Linux
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -41,14 +44,7 @@ fun main() = application {
var currentWindowsIcon by remember { mutableStateOf(Res.drawable.icon) }
var iconTint by remember { mutableStateOf(null) } // null means use default (white/black based on theme)
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Single-instance handling is now managed by nucleusApplication (disabled here)
// Always create the Tray composable, but make it conditional on visibility
val showTray = alwaysShowTray || !isWindowVisible
@@ -144,26 +140,29 @@ fun main() = application {
}
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Platform-Specific Icons Demo",
+ visible = isWindowVisible,
+ icon = painterResource(Res.drawable.icon)
+ ) {
+ TitleBar { Text("Platform-Specific Icons Demo") }
+ nucleusWindow.toFront()
+ App(
+ textVisible = true,
+ alwaysShowTray = alwaysShowTray,
+ hideOnClose = hideOnClose
+ ) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "Platform-Specific Icons Demo",
- visible = isWindowVisible,
- icon = painterResource(Res.drawable.icon)
- ) {
- window.toFront()
- App(
- textVisible = true,
- alwaysShowTray = alwaysShowTray,
- hideOnClose = hideOnClose
- ) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt
index 62394901..7557c850 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt
@@ -3,6 +3,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -11,18 +12,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -35,14 +38,7 @@ fun main() = application {
var alwaysShowTray by remember { mutableStateOf(false) }
var hideOnClose by remember { mutableStateOf(true) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Single-instance handling is now managed by nucleusApplication (disabled here)
// Always create the Tray composable, but make it conditional on visibility
// This ensures it's recomposed when alwaysShowTray changes
@@ -63,21 +59,24 @@ fun main() = application {
)
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Compose Desktop Application with Two Screens",
+ visible = isWindowVisible,
+ icon = org.jetbrains.compose.resources.painterResource(Res.drawable.icon) // Optional: Set window icon
+ ) {
+ TitleBar { Text("Compose Desktop Application with Two Screens") }
+ App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "Compose Desktop Application with Two Screens",
- visible = isWindowVisible,
- icon = org.jetbrains.compose.resources.painterResource(Res.drawable.icon) // Optional: Set window icon
- ) {
- App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicIconsDemo.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicIconsDemo.kt
index e3160226..3f26eda4 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicIconsDemo.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicIconsDemo.kt
@@ -16,25 +16,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.menu.api.Key
import dev.nucleusframework.composenativetray.menu.api.KeyShortcut
import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
-import composenativetray.demo.generated.resources.Res
-import composenativetray.demo.generated.resources.icon
-import org.jetbrains.compose.resources.painterResource
-import kotlin.concurrent.fixedRateTimer
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
/**
* Demo application that showcases dynamic icon changes with callbacks.
* This demo demonstrates how to change icons dynamically based on user interactions.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
// Enable logging for debugging
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -46,15 +44,7 @@ fun main() = application {
var alwaysShowTray by remember { mutableStateOf(true) }
var hideOnClose by remember { mutableStateOf(true) }
- // Ensure single instance
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Single-instance handling is now managed by nucleusApplication (disabled here)
// Basic state for tray icon
var currentTrayIcon by remember { mutableStateOf(Icons.Default.Notifications) }
@@ -226,98 +216,101 @@ fun main() = application {
}
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
- }
- },
- title = "Dynamic Icons Demo",
- visible = isWindowVisible
- ) {
- window.toFront()
-
- // Simple UI to demonstrate icon changes
- MaterialTheme(
- colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Dynamic Icons Demo",
+ visible = isWindowVisible
) {
- Surface {
+ TitleBar { Text("Dynamic Icons Demo") }
+ nucleusWindow.toFront()
+ // Simple UI to demonstrate icon changes
+ MaterialTheme(
+ colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme()
+ ) {
+ Surface {
- Column(
- modifier = Modifier.fillMaxSize().padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- "Dynamic Icons Demo",
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(bottom = 16.dp)
- )
- Text(
- "This demo showcases dynamic icon changes in the system tray menu.",
- modifier = Modifier.padding(bottom = 8.dp)
- )
+ Column(
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ "Dynamic Icons Demo",
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
- Text(
- "Tray menu opened $menuOpenCount times",
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(bottom = 24.dp)
- )
+ Text(
+ "This demo showcases dynamic icon changes in the system tray menu.",
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
- Button(
- onClick = {
- isDarkTheme = !isDarkTheme
- if (isDarkTheme) {
- settingsIcon = Icons.Default.Nightlight
- currentTrayIcon = Icons.Default.DarkMode
- } else {
- settingsIcon = Icons.Default.Settings
- currentTrayIcon = Icons.Default.LightMode
- }
- },
- modifier = Modifier.padding(bottom = 8.dp)
- ) {
- Text("Toggle Theme")
- }
+ Text(
+ "Tray menu opened $menuOpenCount times",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 24.dp)
+ )
- Button(
- onClick = {
- val icons = listOf(
- Icons.Default.WbSunny,
- Icons.Default.Cloud,
- Icons.Default.Opacity,
- Icons.Default.AcUnit
- )
- weatherIcon = icons.random()
- },
- modifier = Modifier.padding(bottom = 8.dp)
- ) {
- Text("Change Weather Icon")
- }
+ Button(
+ onClick = {
+ isDarkTheme = !isDarkTheme
+ if (isDarkTheme) {
+ settingsIcon = Icons.Default.Nightlight
+ currentTrayIcon = Icons.Default.DarkMode
+ } else {
+ settingsIcon = Icons.Default.Settings
+ currentTrayIcon = Icons.Default.LightMode
+ }
+ },
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ Text("Toggle Theme")
+ }
- Button(
- onClick = {
- val icons = listOf(
- Icons.Default.PlayArrow,
- Icons.Default.Pause,
- Icons.Default.Stop
- )
- musicIcon = icons.random()
- },
- modifier = Modifier.padding(bottom = 8.dp)
- ) {
- Text("Change Music Icon")
- }
+ Button(
+ onClick = {
+ val icons = listOf(
+ Icons.Default.WbSunny,
+ Icons.Default.Cloud,
+ Icons.Default.Opacity,
+ Icons.Default.AcUnit
+ )
+ weatherIcon = icons.random()
+ },
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ Text("Change Weather Icon")
+ }
- Button(
- onClick = {
- isWindowVisible = false
+ Button(
+ onClick = {
+ val icons = listOf(
+ Icons.Default.PlayArrow,
+ Icons.Default.Pause,
+ Icons.Default.Stop
+ )
+ musicIcon = icons.random()
+ },
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ Text("Change Music Icon")
+ }
+
+ Button(
+ onClick = {
+ isWindowVisible = false
+ }
+ ) {
+ Text("Hide to Tray")
}
- ) {
- Text("Hide to Tray")
}
}
}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt
index b9ab9194..4809e10f 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt
@@ -1,35 +1,31 @@
package dev.nucleusframework.composenativetray.demo
import androidx.compose.foundation.Image
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
-import dev.nucleusframework.graalvm.GraalVmInitializer
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
-import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import composenativetray.demo.generated.resources.icon2
import org.jetbrains.compose.resources.painterResource
-import java.awt.Color
-import javax.swing.JFrame
-import javax.swing.SwingUtilities
-import javax.swing.Timer
private enum class ServiceStatus {
RUNNING, STOPPED
}
-fun main() {
- GraalVmInitializer.initialize()
- application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
val logTag = "NativeTray"
allowComposeNativeTrayLogging = true
composeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.DEBUG
@@ -42,14 +38,7 @@ fun main() {
var hideOnClose by remember { mutableStateOf(true) }
var serviceStatus by remember { mutableStateOf(ServiceStatus.STOPPED) }
- val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = {
- isWindowVisible = true
- })
-
- if (!isSingleInstance) {
- exitApplication()
- return@application
- }
+ // Single-instance handling is now managed by nucleusApplication (disabled here)
val running = serviceStatus == ServiceStatus.RUNNING
var icon by remember { mutableStateOf(Res.drawable.icon) }
@@ -151,23 +140,25 @@ fun main() {
)
}
- Window(
- onCloseRequest = {
- if (hideOnClose) {
- isWindowVisible = false
- } else {
- exitApplication()
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(
+ onCloseRequest = {
+ if (hideOnClose) {
+ isWindowVisible = false
+ } else {
+ exitApplication()
+ }
+ },
+ title = "Compose Desktop Application with Two Screens",
+ visible = isWindowVisible,
+ icon = painterResource(Res.drawable.icon) // Optional: Set window icon
+ ) {
+ TitleBar { Text("Compose Desktop Application with Two Screens") }
+ nucleusWindow.toFront()
+ App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
+ alwaysShowTray = alwaysShow
+ hideOnClose = hideOnCloseState
}
- },
- title = "Compose Desktop Application with Two Screens",
- visible = isWindowVisible,
- icon = painterResource(Res.drawable.icon) // Optional: Set window icon
- ) {
- window.toFront()
- App(textVisible, alwaysShowTray, hideOnClose) { alwaysShow, hideOnCloseState ->
- alwaysShowTray = alwaysShow
- hideOnClose = hideOnCloseState
}
}
-}
}
\ No newline at end of file
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt
index c97f56ce..18cc5109 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt
@@ -14,34 +14,26 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
+import composenativetray.demo.generated.resources.Res
+import composenativetray.demo.generated.resources.icon
+import dev.nucleusframework.application.SingleInstanceRestoreEffect
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.ExperimentalTrayAppApi
import dev.nucleusframework.composenativetray.tray.api.TrayApp
import dev.nucleusframework.composenativetray.tray.api.TrayWindowDismissMode
import dev.nucleusframework.composenativetray.tray.api.rememberTrayAppState
-import dev.nucleusframework.composenativetray.utils.WindowRaise
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
-import composenativetray.demo.generated.resources.Res
-import composenativetray.demo.generated.resources.icon
import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
-import dev.nucleusframework.graalvm.GraalVmInitializer
+import dev.nucleusframework.window.material.MaterialDecoratedWindow
+import dev.nucleusframework.window.material.MaterialTitleBar
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
-import java.io.File
@OptIn(ExperimentalTrayAppApi::class)
fun main() {
- GraalVmInitializer.initialize()
- if (System.getProperty("skiko.renderApi") == null) {
- val os = System.getProperty("os.name")?.lowercase() ?: ""
- if (os.contains("linux") && isNvidiaGpuPresent()) {
- System.setProperty("skiko.renderApi", "SOFTWARE")
- }
- }
allowComposeNativeTrayLogging = true
- application {
+ nucleusApplication(dockIconFollowsWindows = true) {
var isWindowVisible by remember { mutableStateOf(true) }
val coroutineScope = rememberCoroutineScope()
@@ -62,6 +54,11 @@ fun main() {
}
}
+ // A second launch of the app re-opens the tray popup.
+ SingleInstanceRestoreEffect {
+ trayAppState.show()
+ }
+
TrayApp(
icon = Icons.Default.Window,
state = trayAppState,
@@ -154,15 +151,17 @@ fun main() {
if (isWindowVisible) {
val state = rememberWindowState()
- Window(
- state = state,
- onCloseRequest = { isWindowVisible = false },
- title = "Main App",
- icon = painterResource(Res.drawable.icon),
+ MaterialTheme(
+ colorScheme = if (isSystemInDarkMode()) darkColorScheme() else lightColorScheme()
) {
- MaterialTheme(
- colorScheme = if (isSystemInDarkMode()) darkColorScheme() else lightColorScheme()
+ MaterialDecoratedWindow(
+ state = state,
+ onCloseRequest = { isWindowVisible = false },
+ title = "Main App",
+ icon = painterResource(Res.drawable.icon),
) {
+ MaterialTitleBar { Text("Main App") }
+ val mainWindow = nucleusWindow
Box(
modifier = Modifier
.fillMaxSize()
@@ -219,7 +218,7 @@ fun main() {
}
}
- Divider(modifier = Modifier.padding(vertical = 8.dp))
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
"Window Size Controls",
@@ -267,7 +266,9 @@ fun main() {
// Restore & raise when visibility toggles to true
LaunchedEffect(isWindowVisible) {
if (isWindowVisible) {
- WindowRaise.forceFront(window, state)
+ mainWindow.setMinimized(false)
+ mainWindow.toFront()
+ mainWindow.requestFocus()
}
}
}
@@ -275,20 +276,3 @@ fun main() {
}
}
}
-
-private fun isNvidiaGpuPresent(): Boolean {
- // Check if NVIDIA driver is loaded by looking for the driver version file
- val nvidiaDriverFile = File("/proc/driver/nvidia/version")
- if (nvidiaDriverFile.exists()) return true
-
- // Fallback: try running nvidia-smi
- return try {
- val process = ProcessBuilder("nvidia-smi", "-L")
- .redirectErrorStream(true)
- .start()
- val exitCode = process.waitFor()
- exitCode == 0
- } catch (_: Exception) {
- false
- }
-}
diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/UnicodeTrayDemo.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/UnicodeTrayDemo.kt
index ffbdc3a6..d71804cf 100644
--- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/UnicodeTrayDemo.kt
+++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/UnicodeTrayDemo.kt
@@ -2,14 +2,18 @@ package dev.nucleusframework.composenativetray.demo
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.window.Window
-import androidx.compose.ui.window.application
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.tray.api.Tray
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
+import dev.nucleusframework.window.NucleusDecoratedWindowTheme
+import dev.nucleusframework.window.TitleBar
import composenativetray.demo.generated.resources.Res
import composenativetray.demo.generated.resources.icon
import org.jetbrains.compose.resources.painterResource
@@ -22,7 +26,7 @@ import org.jetbrains.compose.resources.painterResource
* This validates end-to-end UTF-8 → UTF-16 handling on Windows and generic
* Unicode rendering across platforms.
*/
-fun main() = application {
+fun main() = nucleusApplication(enableSingleInstance = false) {
val painter = painterResource(Res.drawable.icon)
var lastClicked by remember { mutableStateOf("-") }
@@ -102,5 +106,9 @@ fun main() = application {
Item(label = "Dernier: $lastClicked", isEnabled = false)
}
- Window(onCloseRequest = ::exitApplication, title = "Unicode Tray Demo") { }
+ NucleusDecoratedWindowTheme(isDark = isSystemInDarkMode()) {
+ DecoratedWindow(onCloseRequest = ::exitApplication, title = "Unicode Tray Demo") {
+ TitleBar { Text("Unicode Tray Demo") }
+ }
+ }
}
diff --git a/docs/PLAN_MACOS_LINUX_PANEL.md b/docs/PLAN_MACOS_LINUX_PANEL.md
new file mode 100644
index 00000000..8bf2ee6d
--- /dev/null
+++ b/docs/PLAN_MACOS_LINUX_PANEL.md
@@ -0,0 +1,171 @@
+# Plan: transparent TrayApp popup on macOS and Linux
+
+Status as of 2026-07-11. Windows is DONE; this document is the hand-off for
+porting the same UX to macOS (and deciding what to do on Linux).
+
+## Where things stand
+
+`TrayApp` (Tao-only, no AWT) routes per platform in
+`src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt`:
+
+- **Windows → `TrayAppImplPanel`**: renders into `TaoStandalonePopup`
+ (public composable in Nucleus `decorated-window-tao`), a top-level,
+ ownerless, topmost, non-activating native panel with per-pixel
+ transparency. Nothing appears in taskbar/Alt-Tab/task view. Slide+fade
+ animations composite over the desktop.
+- **macOS/Linux → `TrayAppImplWindow`**: an opaque `DecoratedWindow`
+ (undecorated, focus-based dismissal). Animations run over the opaque
+ window background — ugly, but currently enabled by default anyway
+ (user decision: same defaults on all platforms until the port lands).
+
+Nucleus source lives at `C:\Users\Elie\IdeaProjects\ComposeDeskKit`,
+published to mavenLocal as `2.0.0-tao-local` — publish commands, module
+list and the GITHUB_REF trick are in the auto-memory
+(`nucleus-local-publish`). Windows pitfalls (focus, AltGr, KEY_TYPED,
+keyLocation, WH_MOUSE_LL, GraalVM metadata) are in the auto-memory
+(`trayapp-transparent-popup`) — read it before touching input code.
+
+## Windows architecture to mirror (reference implementation)
+
+All in `ComposeDeskKit/decorated-window-tao`:
+
+| Piece | File | Role |
+|---|---|---|
+| `TaoStandalonePopup` | `src/main/kotlin/.../tao/TaoStandalonePopup.kt` | Public composable: visible/position/size/focusable/onOutsideClick/key handlers. All host mutations in `LaunchedEffect` (never in `remember{}` — nested composition crash). Bridges outer CompositionLocals into the panel scene. |
+| `TaoStandalonePopupHost` | `src/main/kotlin/.../tao/render/TaoStandalonePopupHost.kt` | Owns the native panel + its own `CanvasLayersComposeScene`. On-demand rendering: `BroadcastFrameClock` + `FlushingDispatcher`, invalidate → `scheduleRender()` → `TaoMainDispatcher`, paced at 60 fps. Input callbacks → scene; keys via `dispatchNativeKeyEvent` (TaoSyntheticKey.kt). |
+| Native panel | `src/main/native/windows/nucleus_tao_windows_popup.c` | Ownerless `WS_POPUP` + NOACTIVATE/TOOLWINDOW/NOREDIRECTIONBITMAP/TOPMOST, DComp alpha, `WH_MOUSE_LL` outside-click, keyboard forwarding + `takeKeyboardFocus()`. |
+| Headless GL bootstrap | `nucleus_tao_gl.c` → `nativeEnsureHeadlessContext()` | Creates the shared process EGL context without any window host — required because TrayApp may show a popup before any window exists. |
+| Smoke test | `src/test/kotlin/.../StandalonePanelNativeSmokeTest.kt` | Validates load → headless ctx → panel → makeCurrent → Skia DirectContext without an event loop. Do the macOS equivalent first. |
+
+## macOS port (the real work)
+
+Goal: `TaoStandalonePopupHost` equivalent backed by an `NSPanel`, then flip
+the router in `TrayApp.kt` so macOS uses `TrayAppImplPanel`.
+
+Existing bricks in `decorated-window-tao` (owner-based popups already work
+on macOS — reuse, don't reinvent):
+
+- `nucleus_tao_macos_popup.m` (or similarly named) behind
+ `PopupNativeBridge` (the non-Windows twin of `PopupNativeBridgeWindows`):
+ NSPanel + transparent CAMetalLayer used by `TaoPopupSceneLayer`.
+- `TaoPopupSceneLayer.kt` — owner-based macOS popup layer; its event
+ callback already uses `dispatchNativeKeyEvent`.
+- Rendering on macOS is Metal (Skia `DirectContext` per host, render
+ thread affine — see `TaoComposeSceneHost.kt`), NOT the EGL path.
+
+Steps:
+
+1. **Native**: extend the macOS popup .m file to allow an ownerless panel
+ (`parentNsView == 0`): `NSPanel` with `.nonactivatingPanel` style,
+ `level = .statusBar` (topmost above normal apps), `isOpaque = false`,
+ clear background, `hidesOnDeactivate = false`, NOT added to any window
+ parent. Screen coordinates (beware: AppKit Y axis is bottom-up —
+ convert from the top-left dp coords the Kotlin side uses).
+2. **Outside click**: use a global `NSEvent.addGlobalMonitorForEvents`
+ (mouseDown/rightMouseDown) + local monitor for clicks inside the app —
+ equivalent of the Windows WH_MOUSE_LL split. The owner-based popups'
+ existing local monitor is not enough for clicks on other apps.
+3. **Keyboard**: `.nonactivatingPanel` + `canBecomeKeyWindow = true`
+ override lets the panel take key focus without activating the app.
+ `makeKeyWindow` on show and on click. Forward `keyDown/keyUp` to the
+ same event-callback wire format (type, vkCode, codePoint, modifiers) —
+ `dispatchNativeKeyEvent` then handles KEY_TYPED + keyLocation for free.
+4. **Host Kotlin**: `TaoStandalonePopupHostMac` mirroring the Windows
+ host but on the Metal render path (no `nativeEnsureHeadlessContext`;
+ create the Metal device/queue standalone — check how
+ `TaoComposeSceneHost` bootstraps Metal and factor if needed).
+ Render on-demand; CAMetalLayer presents are cheap, keep the 60 fps
+ pacing pattern.
+5. **Composable**: extend `TaoStandalonePopup` to route per platform
+ (currently early-returns unless Windows).
+6. **Smoke test** JUnit (guarded on `os.name` = mac) before any UI test.
+7. **TrayApp**: route macOS to `TrayAppImplPanel` in the router, delete
+ the mac branch of `TrayAppImplWindow` once validated. Check
+ `TrayScreenGeometry`/`TrayPosition` for the menu-bar coordinate space
+ (tray icon is top of screen on macOS; `defaultVerticalOffset` is +5).
+8. **GraalVM**: add reachability-metadata.json entries for any new named
+ callback classes (JNI-called classes must be named, not anonymous).
+
+Validation loop (the user runs the demo themselves — do NOT launch it):
+rebuild natives → smoke test → `publishToMavenLocal` (verify the jar
+really contains the fresh dylib, md5 vs `src/main/resources/nucleus/native/…`)
+→ recompile ComposeNativeTray → hand off. Never compile while a demo JVM
+is running.
+
+## Linux (option B — raw X11/XWayland panel, DONE)
+
+Status 2026-07-12: all three platforms are DONE. macOS: Nucleus PR #302.
+Linux: Nucleus PR #305 (merged, `nucleus-2.0`), published as
+`2.0.0-alpha-202607120617` (runtime + plugin), user-validated end-to-end
+on GNOME Wayland. ComposeNativeTray routes Linux → `TrayAppImplPanel`
+gated on `isTaoStandalonePopupAvailable()` (opaque-window fallback for
+X-server-less setups). Position comes from the SNI click coords; the
+work area is answered X11-side (XRandR ∩ `_NET_WORKAREA`) since a
+panel-only app has no realized Tao window for the GDK path.
+
+### Decision: raw X11 window, NOT a GTK window
+
+GDK's backend is process-wide (native Wayland on Wayland sessions), so a
+GTK popup window would only work by forcing the whole app onto XWayland
+(`NUCLEUS_TAO_LINUX_RENDERER=x11`). Instead the panel is a **raw X11
+override-redirect ARGB32 window on its own `XOpenDisplay` connection** —
+an independent X client that works even while the app itself is a native
+Wayland client, via XWayland (present on effectively all desktops).
+Feasibility was validated on GNOME Wayland (Ubuntu, Mutter): OR window
+maps and moves at exact global coords, ARGB visual + desktop-GL 3.3 EGL
+context all work through XWayland.
+
+Native Wayland (no XWayland/DISPLAY) stays unsupported: no layer-shell in
+vendored Tao, `gtk_window_move` is a no-op on xdg-toplevels, `keep_above`
+ignored. In that case the panel reports unavailable and TrayApp falls back
+to the opaque `TrayAppImplWindow`.
+
+### Architecture (mirrors Windows, reuses the Linux EGL bridge)
+
+- **Visual selection**: query EGL first (alpha=8 desktop-GL configs →
+ `EGL_NATIVE_VISUAL_ID`), create the window with exactly that visual —
+ guarantees `NativeTaoEglBridge.nativeAttachX11` matches directly (no
+ child-window fallback, alpha preserved).
+- **Rendering**: reuse `nucleus_tao_egl.c` per-attachment API
+ (`nativeAttachX11`/`nativeMakeCurrent`/`nativePresent`/`nativeResize` +
+ `nativeGetProcAddrFunctionPointer`) — Linux convention is one EGL context
+ per surface, no headless bootstrap needed. `eglSwapInterval(0)` +
+ the 60 fps pacer pattern from the Windows host (don't block the Tao
+ main thread on vsync).
+- **Threading**: two X connections. Command connection owned by the Tao
+ main thread (create/move/map/cursor/focus + EGL). Event connection owned
+ by a dedicated per-panel thread (`XSelectInput` on the panel XID works
+ cross-connection); quit via ClientMessage. No `XInitThreads` dependency.
+- **Input**: Button/Motion/scroll(buttons 4-7) → `TaoNativeWireFormat`
+ pointer wire; keys send the raw **X keysym** as vkCode + Unicode
+ codePoint; a new `linuxNativeKeyToAwt` in `dispatchNativeKeyEvent`
+ translates keysym → AWT VK (same pattern as `macNativeKeyToAwt`).
+- **Keyboard focus**: `XSetInputFocus(RevertToParent)` on click while
+ focusable (Windows `takeKeyboardFocus` equivalent). If some WMs don't
+ deliver keys to OR windows, escalate to `XGrabKeyboard` later.
+- **Outside-click**: XI2 raw ButtonPress on the root window (the X11
+ analog of `WH_MOUSE_LL`; multiple clients allowed, no grab, doesn't
+ consume). Fully global on X11 sessions; under XWayland only fires while
+ X11 surfaces have input — acceptable because the tray-icon toggle and
+ focus-loss cover the rest.
+- **Scale**: `Xft.dpi`-derived (X clients live in the X coordinate space,
+ which under XWayland is logical — GDK's Wayland scale would mis-size the
+ panel). Fractional/HiDPI XWayland caveats accepted for v1.
+
+New files: `nucleus_tao_linux_popup.c` (→ `libnucleus_tao_linux_popup.so`,
+dlopen libX11/libXi only, linked `-ldl` like siblings),
+`PopupNativeBridgeLinux.kt`, `render/TaoStandalonePopupHostLinux.kt`,
+`render/TaoKeyLinux.kt`, smoke test guarded on Linux + `DISPLAY`. Plus:
+route Linux in `TaoStandalonePopup.kt`, a public availability check for
+TrayApp's router, `linux/build.sh` step, GraalVM reachability metadata,
+CI `build-natives.yaml` + verify arrays in consumer workflows.
+
+## Also pending (unrelated to the port)
+
+- README still documents the pre-Tao API; rewrite around
+ `nucleusApplication` + `NucleusApplicationScope.TrayApp`.
+- No IME composition in the Windows panel (WM_CHAR/dead keys/CJK); direct
+ Latin input only. Port `startInputMethod` plumbing if needed.
+- `TaoWindow.setInvisibleHelperStyle()` and the `nativePopupLayers` flag
+ in Nucleus are leftovers from the abandoned anchor-window design; safe
+ to remove after checking for external users.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3cf6c1a8..e9a0bc7f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,15 +5,18 @@ kotlinx-coroutines = "1.11.0"
compose = "1.11.1"
detekt = "1.23.8"
ktlint = "12.1.2"
-nucleus = "2.0.0-alpha-202607101252"
+nucleus = "2.0.3"
+nucleus-plugin = "2.0.3"
[libraries]
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
-kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleus" }
nucleus-darkmode-detector = { module = "dev.nucleusframework:nucleus.darkmode-detector", version.ref = "nucleus" }
nucleus-graalvm-runtime = { module = "dev.nucleusframework:nucleus.graalvm-runtime", version.ref = "nucleus" }
+nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleus" }
+nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleus" }
+nucleus-decorated-window-material3 = { module = "dev.nucleusframework:nucleus.decorated-window-material3", version.ref = "nucleus" }
[plugins]
multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
@@ -23,4 +26,4 @@ vannitktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.
dokka = { id = "org.jetbrains.dokka", version = "2.2.0" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
-nucleus = { id = "dev.nucleusframework", version.ref = "nucleus" }
+nucleus = { id = "dev.nucleusframework", version.ref = "nucleus-plugin" }
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxOutsideClickWatcher.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxOutsideClickWatcher.kt
deleted file mode 100644
index 1c25164f..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxOutsideClickWatcher.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package dev.nucleusframework.composenativetray.lib.linux
-
-import dev.nucleusframework.composenativetray.utils.isPointWithinLinuxStatusItem
-import dev.nucleusframework.core.runtime.Platform
-import java.awt.Window
-import java.util.concurrent.Executors
-import java.util.concurrent.ScheduledExecutorService
-import java.util.concurrent.TimeUnit
-
-/**
- * LinuxOutsideClickWatcher: X11/XWayland implementation that detects a left-click anywhere,
- * and if it is outside the supplied window (and not on the tray icon area), invokes a callback.
- *
- * Uses JNI via LinuxNativeBridge for X11 calls (no JNA dependency).
- *
- * Notes:
- * - Requires X11/XWayland (DISPLAY must be set). Will no-op on Wayland-only sessions without XWayland.
- * - Polls with XQueryPointer at ~60 Hz, reading Button1Mask for "left pressed".
- */
-class LinuxOutsideClickWatcher(
- private val windowSupplier: () -> Window?,
- private val onOutsideClick: () -> Unit,
-) : AutoCloseable {
- private var scheduler: ScheduledExecutorService? = null
- private var prevLeft = false
-
- // X11 state (native handles)
- private var displayHandle: Long = 0L
- private var rootWindow: Long = 0L
-
- fun start() {
- if (Platform.Current != Platform.Linux) return
- if (scheduler != null) return
-
- try {
- displayHandle = LinuxNativeBridge.nativeX11OpenDisplay()
- if (displayHandle == 0L) return
- rootWindow = LinuxNativeBridge.nativeX11DefaultRootWindow(displayHandle)
- } catch (_: Throwable) {
- displayHandle = 0L
- return
- }
-
- scheduler =
- Executors.newSingleThreadScheduledExecutor { r ->
- Thread(r, "LinuxOutsideClickWatcher").apply { isDaemon = true }
- }.also { exec ->
- exec.scheduleAtFixedRate({ pollOnce() }, 0, 16, TimeUnit.MILLISECONDS)
- }
- }
-
- private fun pollOnce() {
- if (displayHandle == 0L) return
- try {
- val outData = IntArray(3) // [rootX, rootY, mask]
- val ok = LinuxNativeBridge.nativeX11QueryPointer(displayHandle, rootWindow, outData) != 0
- if (!ok) return
-
- val px = outData[0]
- val py = outData[1]
- val mask = outData[2]
- val left = (mask and BUTTON1_MASK) != 0
-
- // Rising edge: only act once when the button goes down
- if (left && left != prevLeft) {
- val win = windowSupplier.invoke()
- if (win != null && win.isShowing) {
- val winLoc =
- try {
- win.locationOnScreen
- } catch (_: Throwable) {
- null
- }
- if (winLoc != null) {
- val wx = winLoc.x
- val wy = winLoc.y
- val ww = win.width
- val wh = win.height
-
- val insideWindow = px >= wx && px < wx + ww && py >= wy && py < wy + wh
- val onTrayIcon =
- try {
- isPointWithinLinuxStatusItem(px, py)
- } catch (_: Throwable) {
- false
- }
-
- if (!insideWindow && !onTrayIcon) {
- onOutsideClick.invoke()
- }
- }
- }
- }
-
- prevLeft = left
- } catch (_: Throwable) {
- // Swallow errors to keep the scheduler alive
- }
- }
-
- fun stop() = close()
-
- override fun close() {
- try {
- scheduler?.shutdownNow()
- } catch (_: Throwable) {
- }
- scheduler = null
-
- try {
- if (displayHandle != 0L) LinuxNativeBridge.nativeX11CloseDisplay(displayHandle)
- } catch (_: Throwable) {
- } finally {
- displayHandle = 0L
- rootWindow = 0L
- }
- }
-
- private companion object {
- // X11 button mask bit for Button1 (left)
- private const val BUTTON1_MASK = 1 shl 8
- }
-}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOSWindowManager.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOSWindowManager.kt
index 0c4205a5..10818e41 100644
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOSWindowManager.kt
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOSWindowManager.kt
@@ -47,18 +47,19 @@ class MacOSWindowManager {
}
/**
- * Configure an AWT window so that macOS moves it to the active Space
- * when it is ordered front, instead of switching back to the Space
- * where the window was originally created.
+ * Configure a window so that macOS moves it to the active Space when it
+ * is ordered front, instead of switching back to the Space where the
+ * window was originally created.
+ *
+ * @param nsViewPtr NSView pointer of the window's content view (on the
+ * Tao backend this is `TaoWindow.nativeHandle`).
*/
- fun setMoveToActiveSpace(awtWindow: java.awt.Window): Boolean {
+ fun setMoveToActiveSpace(nsViewPtr: Long): Boolean {
if (!isMacOs) return false
return try {
- // Try direct approach via JAWT to get NSView pointer
- val viewPtr = MacNativeBridge.nativeGetAWTViewPtr(awtWindow)
- debugln { "[MacOSWindowManager] setMoveToActiveSpace: viewPtr=$viewPtr" }
- if (viewPtr != 0L) {
- val result = MacNativeBridge.nativeSetMoveToActiveSpaceForWindow(viewPtr)
+ debugln { "[MacOSWindowManager] setMoveToActiveSpace: viewPtr=$nsViewPtr" }
+ if (nsViewPtr != 0L) {
+ val result = MacNativeBridge.nativeSetMoveToActiveSpaceForWindow(nsViewPtr)
if (result == 0) return true
}
@@ -87,16 +88,17 @@ class MacOSWindowManager {
}
/**
- * Check if an AWT window is currently on the active macOS Space.
+ * Check if a window is currently on the active macOS Space.
* Returns true if on the active Space, false if on another Space.
* Returns true by default if the check cannot be performed (fail-open).
+ *
+ * @param nsViewPtr NSView pointer of the window's content view.
*/
- fun isOnActiveSpace(awtWindow: java.awt.Window): Boolean {
+ fun isOnActiveSpace(nsViewPtr: Long): Boolean {
if (!isMacOs) return true
return try {
- val viewPtr = MacNativeBridge.nativeGetAWTViewPtr(awtWindow)
- if (viewPtr == 0L) return true
- MacNativeBridge.nativeIsOnActiveSpaceForView(viewPtr) != 0
+ if (nsViewPtr == 0L) return true
+ MacNativeBridge.nativeIsOnActiveSpaceForView(nsViewPtr) != 0
} catch (e: Throwable) {
debugln { "Failed to check isOnActiveSpace: ${e.message}" }
true // fail-open: assume on active Space
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOutsideClickWatcher.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOutsideClickWatcher.kt
deleted file mode 100644
index 6d981a82..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/mac/MacOutsideClickWatcher.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package dev.nucleusframework.composenativetray.lib.mac
-
-import dev.nucleusframework.composenativetray.utils.isPointWithinMacStatusItem
-import dev.nucleusframework.core.runtime.Platform
-import java.awt.MouseInfo
-import java.awt.Window
-import java.util.concurrent.Executors
-import java.util.concurrent.ScheduledExecutorService
-import java.util.concurrent.TimeUnit
-
-/**
- * MacOutsideClickWatcher: encapsulates macOS-specific logic to detect a left-click
- * outside the provided window and invoke a callback to hide it. It also ignores
- * clicks on the macOS status bar tray icon (status item) so that clicking the tray icon
- * does not spuriously hide the window.
- */
-class MacOutsideClickWatcher(
- private val windowSupplier: () -> Window?,
- private val onOutsideClick: () -> Unit,
-) : AutoCloseable {
- private var scheduler: ScheduledExecutorService? = null
- private var prevLeft = false
-
- fun start() {
- if (Platform.Current != Platform.MacOS) return
- if (scheduler != null) return
- scheduler =
- Executors.newSingleThreadScheduledExecutor { r ->
- Thread(r, "MacOutsideClickWatcher").apply { isDaemon = true }
- }.also { exec ->
- exec.scheduleAtFixedRate({ pollOnce() }, 0, 16, TimeUnit.MILLISECONDS)
- }
- }
-
- private fun pollOnce() {
- try {
- val left = MacNativeBridge.nativeGetMouseButtonState(0) != 0
-
- if (left && left != prevLeft) {
- val win = windowSupplier.invoke()
- if (win != null && win.isShowing) {
- val pointer =
- try {
- MouseInfo.getPointerInfo()
- } catch (_: Throwable) {
- null
- }
- val loc = pointer?.location
- if (loc != null) {
- val px = loc.x
- val py = loc.y
- val winLoc =
- try {
- win.locationOnScreen
- } catch (_: Throwable) {
- null
- }
- if (winLoc != null) {
- val wx = winLoc.x
- val wy = winLoc.y
- val ww = win.width
- val wh = win.height
- val insideWindow = px >= wx && px < wx + ww && py >= wy && py < wy + wh
- val onTrayIcon = isPointWithinMacStatusItem(px, py)
- if (!insideWindow && !onTrayIcon) {
- onOutsideClick.invoke()
- }
- }
- }
- }
- }
- prevLeft = left
- } catch (_: Throwable) {
- // Swallow errors to avoid crashing the scheduler
- }
- }
-
- fun stop() = close()
-
- override fun close() {
- try {
- scheduler?.shutdownNow()
- } catch (_: Throwable) {
- }
- scheduler = null
- }
-}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsOutsideClickWatcher.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsOutsideClickWatcher.kt
deleted file mode 100644
index 97b6e40d..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsOutsideClickWatcher.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package dev.nucleusframework.composenativetray.lib.windows
-
-import dev.nucleusframework.core.runtime.Platform
-import java.awt.Window
-import java.util.concurrent.atomic.AtomicBoolean
-
-/**
- * WindowsOutsideClickWatcher using a low-level mouse hook (WH_MOUSE_LL) via JNI.
- *
- * Behavior:
- * - Listens for global left-button *down* events.
- * - If the click occurs outside the supplied window (and not ignored by predicate), invokes onOutsideClick().
- *
- * Public signatures are preserved.
- */
-class WindowsOutsideClickWatcher(
- private val windowSupplier: () -> Window?,
- private val onOutsideClick: () -> Unit,
- private val ignorePointPredicate: ((x: Int, y: Int) -> Boolean)? = null,
-) : AutoCloseable {
- @Volatile private var hookThread: Thread? = null
-
- @Volatile private var hookId: Long = 0L
- private val stopping = AtomicBoolean(false)
-
- /** Start the global low-level mouse hook on a dedicated daemon thread. */
- fun start() {
- if (Platform.Current != Platform.Windows) return
- synchronized(this) {
- if (hookThread != null) return
- stopping.set(false)
-
- hookThread =
- Thread({
- val callback =
- Runnable {
- try {
- val xy = IntArray(2)
- WindowsNativeBridge.nativeGetLastMouseHookClick(xy)
- val px = xy[0]
- val py = xy[1]
-
- val win = windowSupplier()
- if (win != null && win.isShowing) {
- val winLoc =
- try {
- win.locationOnScreen
- } catch (_: Throwable) {
- null
- }
- if (winLoc != null) {
- // Get the graphics configuration to determine the DPI scale
- val scale =
- try {
- win.graphicsConfiguration?.defaultTransform?.scaleX ?: 1.0
- } catch (_: Throwable) {
- 1.0
- }
-
- // Convert window bounds from logical to physical pixels
- val wx = (winLoc.x * scale).toInt()
- val wy = (winLoc.y * scale).toInt()
- val ww = (win.width * scale).toInt()
- val wh = (win.height * scale).toInt()
-
- val insideWindow = px in wx until (wx + ww) && py in wy until (wy + wh)
- val ignored = ignorePointPredicate?.invoke(px, py) == true
-
- if (!insideWindow && !ignored) {
- // Let caller decide EDT marshaling.
- onOutsideClick()
- }
- }
- }
- } catch (_: Throwable) {
- // Never crash the hook callback
- }
- }
-
- hookId = WindowsNativeBridge.nativeInstallMouseHook(callback)
- if (hookId == 0L) return@Thread
-
- // Block on message loop until stopped
- WindowsNativeBridge.nativeRunMouseHookLoop(hookId)
- }, "WindowsOutsideClickWatcher-LL").apply {
- isDaemon = true
- start()
- }
- }
- }
-
- /** Stop the hook (alias to close()). */
- fun stop() = close()
-
- /** Uninstalls the hook and signals the hook thread to exit. */
- override fun close() {
- synchronized(this) {
- stopping.set(true)
-
- val id = hookId
- if (id != 0L) {
- try {
- WindowsNativeBridge.nativeStopMouseHook(id)
- } catch (_: Throwable) {
- }
- hookId = 0L
- }
-
- hookThread = null
- }
- }
-}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt
index dd1915d6..4ab06344 100644
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt
@@ -1,6 +1,7 @@
package dev.nucleusframework.composenativetray.lib.windows
import dev.nucleusframework.composenativetray.utils.TrayClickTracker
+import dev.nucleusframework.composenativetray.utils.TrayScreenGeometry
import dev.nucleusframework.composenativetray.utils.convertPositionToCorner
import dev.nucleusframework.composenativetray.utils.debugln
import kotlinx.coroutines.CoroutineScope
@@ -253,21 +254,21 @@ internal class WindowsTrayManager(
val precise = WindowsNativeBridge.nativeGetNotificationIconsPosition(outXY) != 0
log("nativeGetNotificationIconsPosition: precise=$precise, rawX=${outXY[0]}, rawY=${outXY[1]}")
if (precise) {
- // Native coordinates are in physical pixels, but AWT uses logical pixels.
- // Convert physical to logical by dividing by the DPI scale factor.
- val scale = getDpiScale(outXY[0], outXY[1])
+ // Native coordinates are in physical pixels; window positioning
+ // works in logical pixels. Convert via the primary monitor scale.
+ val scale = TrayScreenGeometry.scale()
val logicalX = (outXY[0] / scale).toInt()
val logicalY = (outXY[1] / scale).toInt()
- val screen = java.awt.Toolkit.getDefaultToolkit().screenSize
+ val screen = TrayScreenGeometry.workAreaLogical()
log(
"DPI scale=$scale, logicalX=$logicalX, logicalY=$logicalY, " +
"screenW=${screen.width}, screenH=${screen.height}",
)
val corner =
convertPositionToCorner(
- logicalX,
- logicalY,
+ logicalX - screen.x,
+ logicalY - screen.y,
screen.width,
screen.height,
)
@@ -297,52 +298,6 @@ internal class WindowsTrayManager(
safeGetTrayPosition(instanceId)
}
- /**
- * Gets the DPI scale factor for the screen containing the given physical coordinates.
- * Returns 1.0 for 100% scaling, 1.25 for 125%, 1.5 for 150%, etc.
- *
- * @param physicalX X coordinate in physical pixels (optional, uses primary screen if not provided)
- * @param physicalY Y coordinate in physical pixels (optional, uses primary screen if not provided)
- */
- private fun getDpiScale(
- physicalX: Int? = null,
- physicalY: Int? = null,
- ): Double {
- return try {
- val ge = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
-
- // If coordinates provided, try to find the screen containing those coordinates
- if (physicalX != null && physicalY != null) {
- for (gd in ge.screenDevices) {
- val config = gd.defaultConfiguration
- val scale = config.defaultTransform.scaleX
- // Convert physical bounds to check containment
- val bounds = config.bounds
- val physBounds =
- java.awt.Rectangle(
- (bounds.x * scale).toInt(),
- (bounds.y * scale).toInt(),
- (bounds.width * scale).toInt(),
- (bounds.height * scale).toInt(),
- )
- if (physBounds.contains(physicalX, physicalY)) {
- return scale
- }
- }
- }
-
- // Fallback to primary screen
- ge.defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX
- } catch (_: Throwable) {
- // Fallback: use screen resolution (96 DPI = 100%)
- try {
- java.awt.Toolkit.getDefaultToolkit().screenResolution / 96.0
- } catch (_: Throwable) {
- 1.0
- }
- }
- }
-
private fun processUpdateQueue() {
val update =
synchronized(updateQueueLock) {
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/AwtTrayMenuBuilderImpl.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/AwtTrayMenuBuilderImpl.kt
deleted file mode 100644
index 80718115..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/menu/impl/AwtTrayMenuBuilderImpl.kt
+++ /dev/null
@@ -1,215 +0,0 @@
-package dev.nucleusframework.composenativetray.menu.impl
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.graphics.vector.ImageVector
-import dev.nucleusframework.composenativetray.menu.api.KeyShortcut
-import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder
-import dev.nucleusframework.composenativetray.utils.IconRenderProperties
-import org.jetbrains.compose.resources.DrawableResource
-import java.awt.MenuItem
-import java.awt.PopupMenu
-import java.awt.SystemTray
-import java.awt.TrayIcon
-
-internal class AwtTrayMenuBuilderImpl(
- private val popupMenu: PopupMenu,
- private val trayIcon: TrayIcon,
-) : TrayMenuBuilder {
- override fun Item(
- label: String,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- onClick: () -> Unit,
- ) {
- val menuItem = MenuItem(label)
- menuItem.isEnabled = isEnabled
- menuItem.addActionListener {
- onClick()
- }
- popupMenu.add(menuItem)
- }
-
- override fun Item(
- label: String,
- iconContent: @Composable () -> Unit,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- onClick: () -> Unit,
- ) {
- Item(label, isEnabled, shortcut, onClick)
- }
-
- override fun Item(
- label: String,
- icon: ImageVector,
- iconTint: Color?,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- onClick: () -> Unit,
- ) {
- Item(label, isEnabled, shortcut, onClick)
- }
-
- override fun Item(
- label: String,
- icon: Painter,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- onClick: () -> Unit,
- ) {
- Item(label, isEnabled, shortcut, onClick)
- }
-
- override fun Item(
- label: String,
- icon: DrawableResource,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- onClick: () -> Unit,
- ) {
- Item(label, isEnabled, shortcut, onClick)
- }
-
- override fun CheckableItem(
- label: String,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- ) {
- var currentChecked = checked
- val checkableMenuItem = MenuItem(getCheckableLabel(label, currentChecked))
- checkableMenuItem.isEnabled = isEnabled
-
- checkableMenuItem.addActionListener {
- val newChecked = !currentChecked
- currentChecked = newChecked
- checkableMenuItem.label = getCheckableLabel(label, newChecked)
- onCheckedChange(newChecked)
- }
-
- popupMenu.add(checkableMenuItem)
- }
-
- override fun CheckableItem(
- label: String,
- iconContent: @Composable () -> Unit,
- iconRenderProperties: IconRenderProperties,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- ) {
- CheckableItem(label, checked, onCheckedChange, isEnabled, shortcut)
- }
-
- override fun CheckableItem(
- label: String,
- icon: ImageVector,
- iconTint: Color?,
- iconRenderProperties: IconRenderProperties,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- ) {
- CheckableItem(label, checked, onCheckedChange, isEnabled, shortcut)
- }
-
- override fun CheckableItem(
- label: String,
- icon: Painter,
- iconRenderProperties: IconRenderProperties,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- ) {
- CheckableItem(label, checked, onCheckedChange, isEnabled, shortcut)
- }
-
- override fun CheckableItem(
- label: String,
- icon: DrawableResource,
- iconRenderProperties: IconRenderProperties,
- checked: Boolean,
- onCheckedChange: (Boolean) -> Unit,
- isEnabled: Boolean,
- shortcut: KeyShortcut?,
- ) {
- CheckableItem(label, checked, onCheckedChange, isEnabled, shortcut)
- }
-
- override fun SubMenu(
- label: String,
- isEnabled: Boolean,
- submenuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- val subMenu = PopupMenu(label)
- subMenu.isEnabled = isEnabled
- submenuContent?.let { AwtTrayMenuBuilderImpl(subMenu, trayIcon).apply(it) }
- popupMenu.add(subMenu)
- }
-
- override fun SubMenu(
- label: String,
- iconContent: @Composable () -> Unit,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- submenuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- SubMenu(label, isEnabled, submenuContent)
- }
-
- override fun SubMenu(
- label: String,
- icon: ImageVector,
- iconTint: Color?,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- submenuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- SubMenu(label, isEnabled, submenuContent)
- }
-
- override fun SubMenu(
- label: String,
- icon: Painter,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- submenuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- SubMenu(label, isEnabled, submenuContent)
- }
-
- override fun SubMenu(
- label: String,
- icon: DrawableResource,
- iconRenderProperties: IconRenderProperties,
- isEnabled: Boolean,
- submenuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- SubMenu(label, isEnabled, submenuContent)
- }
-
- override fun Divider() {
- popupMenu.addSeparator()
- }
-
- override fun dispose() {
- SystemTray.getSystemTray().remove(trayIcon)
- }
-
- private fun getCheckableLabel(
- label: String,
- isChecked: Boolean,
- ): String {
- return if (isChecked) "✔ $label" else label
- }
-}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/ComposableTray.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/ComposableTray.kt
index 5f9efe40..20b13455 100644
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/ComposableTray.kt
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/ComposableTray.kt
@@ -5,7 +5,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.window.ApplicationScope
+import dev.nucleusframework.application.NucleusApplicationScope
import dev.nucleusframework.composenativetray.menu.api.ComposableTrayMenuScope
import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder
import dev.nucleusframework.composenativetray.menu.impl.RecordingComposableScope
@@ -41,7 +41,7 @@ private fun rememberRecordedMenuContent(
* hoist a `val icon = painterResource(...)` above `application { … }`.
*/
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
icon: Painter,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -61,7 +61,7 @@ fun ApplicationScope.Tray(
}
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
icon: ImageVector,
tint: Color? = null,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
@@ -83,7 +83,7 @@ fun ApplicationScope.Tray(
}
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
icon: DrawableResource,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -104,7 +104,7 @@ fun ApplicationScope.Tray(
}
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
iconContent: @Composable () -> Unit,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -127,7 +127,7 @@ fun ApplicationScope.Tray(
* Polymorphic helper: [Painter] icon on Windows, [ImageVector] on macOS/Linux, composable menu.
*/
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
windowsIcon: Painter,
macLinuxIcon: ImageVector,
tint: Color? = null,
@@ -151,7 +151,7 @@ fun ApplicationScope.Tray(
}
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
windowsIcon: DrawableResource,
macLinuxIcon: ImageVector,
tint: Color? = null,
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt
index 9535dcb3..9e24ca35 100644
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt
@@ -12,9 +12,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.window.ApplicationScope
+import dev.nucleusframework.application.NucleusApplicationScope
import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder
-import dev.nucleusframework.composenativetray.tray.impl.AwtTrayInitializer
import dev.nucleusframework.composenativetray.tray.impl.LinuxTrayInitializer
import dev.nucleusframework.composenativetray.tray.impl.MacTrayInitializer
import dev.nucleusframework.composenativetray.tray.impl.WindowsTrayInitializer
@@ -25,12 +24,12 @@ import dev.nucleusframework.composenativetray.utils.debugln
import dev.nucleusframework.composenativetray.utils.errorln
import dev.nucleusframework.composenativetray.utils.extractToTempIfDifferent
import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode
-import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
import dev.nucleusframework.core.runtime.Platform
import dev.nucleusframework.core.runtime.Platform.Linux
import dev.nucleusframework.core.runtime.Platform.MacOS
import dev.nucleusframework.core.runtime.Platform.Unknown
import dev.nucleusframework.core.runtime.Platform.Windows
+import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -38,14 +37,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
-import java.util.concurrent.atomic.AtomicBoolean
import kotlin.internal.LowPriorityInOverloadResolution
internal class NativeTray {
private val trayScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- private val awtTrayUsed = AtomicBoolean(false)
-
private val os = Platform.Current
private val instanceId: String = "tray-" + System.identityHashCode(this)
private var initialized = false
@@ -96,10 +92,7 @@ internal class NativeTray {
menuContent,
onMenuOpened,
)
- Unknown -> {
- AwtTrayInitializer.update(iconPath, tooltip, primaryAction, menuContent)
- awtTrayUsed.set(true)
- }
+ Unknown -> errorln { "[NativeTray] Unsupported platform: no system tray backend available" }
else -> {}
}
} catch (th: Throwable) {
@@ -171,10 +164,8 @@ internal class NativeTray {
menuContent,
onMenuOpened,
)
- Unknown -> {
- AwtTrayInitializer.update(pngIconPath, tooltip, primaryAction, menuContent)
- awtTrayUsed.set(true)
- }
+ Unknown ->
+ errorln { "[NativeTray] Unsupported platform: no system tray backend available" }
else -> {}
}
} catch (th: Throwable) {
@@ -253,7 +244,7 @@ internal class NativeTray {
Linux -> LinuxTrayInitializer.dispose(instanceId)
Windows -> WindowsTrayInitializer.dispose(instanceId)
MacOS -> MacTrayInitializer.dispose(instanceId)
- Unknown -> if (awtTrayUsed.get()) AwtTrayInitializer.dispose()
+ Unknown -> {}
else -> {}
}
initialized = false
@@ -322,19 +313,8 @@ internal class NativeTray {
errorln { "[NativeTray] Error initializing tray: $th" }
}
- val awtTrayRequired = os == Unknown || !trayInitialized
- if (awtTrayRequired) {
- if (AwtTrayInitializer.isSupported()) {
- try {
- debugln { "[NativeTray] Initializing AWT tray with icon path: $iconPath" }
- AwtTrayInitializer.initialize(iconPath, tooltip, primaryAction, menuContent)
- awtTrayUsed.set(true)
- } catch (th: Throwable) {
- errorln { "[NativeTray] Error initializing AWT tray: $th" }
- }
- } else {
- debugln { "[NativeTray] AWT tray is not supported" }
- }
+ if (os == Unknown || !trayInitialized) {
+ errorln { "[NativeTray] No native tray backend could be initialized on this platform" }
}
}
}
@@ -370,7 +350,7 @@ internal class NativeTray {
replaceWith = ReplaceWith("Tray(iconContent, tooltip, primaryAction, menuContent)"),
)
@Composable
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
iconPath: String,
windowsIconPath: String = iconPath,
tooltip: String,
@@ -409,7 +389,7 @@ fun ApplicationScope.Tray(
@Composable
@LowPriorityInOverloadResolution
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
iconContent: @Composable () -> Unit,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -451,7 +431,7 @@ fun ApplicationScope.Tray(
@Composable
@LowPriorityInOverloadResolution
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
icon: ImageVector,
tint: Color? = null,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
@@ -547,7 +527,7 @@ fun ApplicationScope.Tray(
@Composable
@LowPriorityInOverloadResolution
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
icon: Painter,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -603,7 +583,7 @@ fun ApplicationScope.Tray(
*/
@Composable
@LowPriorityInOverloadResolution
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
windowsIcon: Painter,
macLinuxIcon: ImageVector,
tint: Color? = null,
@@ -644,7 +624,7 @@ fun ApplicationScope.Tray(
*/
@Composable
@LowPriorityInOverloadResolution
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
icon: DrawableResource,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -666,7 +646,7 @@ fun ApplicationScope.Tray(
@Composable
@LowPriorityInOverloadResolution
-fun ApplicationScope.Tray(
+fun NucleusApplicationScope.Tray(
windowsIcon: DrawableResource,
macLinuxIcon: ImageVector,
tint: Color? = null,
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt
index 091d8b4f..a4248af8 100644
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt
@@ -38,43 +38,46 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.ApplicationScope
-import androidx.compose.ui.window.DialogWindow
-import androidx.compose.ui.window.DialogWindowScope
import androidx.compose.ui.window.WindowPosition
-import androidx.compose.ui.window.rememberDialogState
-import dev.nucleusframework.composenativetray.lib.linux.LinuxOutsideClickWatcher
-import dev.nucleusframework.composenativetray.lib.mac.MacNativeBridge
-import dev.nucleusframework.composenativetray.lib.mac.MacOSWindowManager
-import dev.nucleusframework.composenativetray.lib.mac.MacOutsideClickWatcher
-import dev.nucleusframework.composenativetray.lib.windows.WindowsOutsideClickWatcher
+import androidx.compose.ui.window.rememberWindowState
+import dev.nucleusframework.application.DecoratedWindow
+import dev.nucleusframework.application.NucleusApplicationScope
+import dev.nucleusframework.application.NucleusBackend
import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder
import dev.nucleusframework.composenativetray.tray.impl.WindowsTrayInitializer
import dev.nucleusframework.composenativetray.utils.ComposableIconUtils
import dev.nucleusframework.composenativetray.utils.IconRenderProperties
import dev.nucleusframework.composenativetray.utils.MenuContentHash
import dev.nucleusframework.composenativetray.utils.PersistentAnimatedVisibility
-import dev.nucleusframework.composenativetray.utils.WindowVisibilityMonitor
+import dev.nucleusframework.composenativetray.utils.TrayScreenGeometry
import dev.nucleusframework.composenativetray.utils.debugln
+import dev.nucleusframework.composenativetray.utils.getTrayWindowPosition
import dev.nucleusframework.composenativetray.utils.getTrayWindowPositionForInstance
import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode
import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment
import dev.nucleusframework.core.runtime.Platform
+import dev.nucleusframework.window.tao.TaoStandalonePopup
+import dev.nucleusframework.window.tao.isTaoStandalonePopupAvailable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
-import java.awt.EventQueue.invokeLater
-import java.awt.event.WindowEvent
-import java.awt.event.WindowFocusListener
import java.util.concurrent.atomic.AtomicLong
// --------------------- Public API (defaults) ---------------------
+// On Windows + macOS the popup renders in a per-pixel transparent native panel,
+// so content animations composite over the desktop. On Linux the popup is
+// still an opaque Tao window (no cross-WM transparency equivalent): the
+// animation runs over the bare window background. See
+// docs/PLAN_MACOS_LINUX_PANEL.md.
+//
+// Defaults match the historical per-platform behaviour: Windows tray popups
+// slide up from the taskbar; macOS/Linux use a plain fade (menu-bar popups
+// don't slide on those platforms; KDE gets a near-instant fade).
private val defaultTrayAppEnterTransition =
if (Platform.Current == Platform.Windows) {
slideInVertically(
@@ -122,7 +125,7 @@ private val defaultVerticalOffset =
@ExperimentalTrayAppApi
@Composable
-fun ApplicationScope.TrayApp(
+fun NucleusApplicationScope.TrayApp(
icon: ImageVector,
tint: Color? = null,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
@@ -132,7 +135,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart: Boolean = false,
enterTransition: EnterTransition = defaultTrayAppEnterTransition,
exitTransition: ExitTransition = defaultTrayAppExitTransition,
- transparent: Boolean = true,
windowsTitle: String = "",
windowIcon: Painter? = null,
undecorated: Boolean = true,
@@ -142,7 +144,7 @@ fun ApplicationScope.TrayApp(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
menu: (TrayMenuBuilder.() -> Unit)? = null,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
val iconContent: @Composable () -> Unit = {
val isDark = isMenuBarInDarkMode()
@@ -164,7 +166,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
@@ -180,7 +181,7 @@ fun ApplicationScope.TrayApp(
@ExperimentalTrayAppApi
@Composable
-fun ApplicationScope.TrayApp(
+fun NucleusApplicationScope.TrayApp(
icon: Painter,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -189,7 +190,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart: Boolean = false,
enterTransition: EnterTransition = defaultTrayAppEnterTransition,
exitTransition: ExitTransition = defaultTrayAppExitTransition,
- transparent: Boolean = true,
windowsTitle: String = "",
windowIcon: Painter? = null,
undecorated: Boolean = true,
@@ -199,7 +199,7 @@ fun ApplicationScope.TrayApp(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
menu: (TrayMenuBuilder.() -> Unit)? = null,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
val iconContent: @Composable () -> Unit = {
Image(painter = icon, contentDescription = null, modifier = Modifier.fillMaxSize())
@@ -213,7 +213,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
@@ -230,7 +229,7 @@ fun ApplicationScope.TrayApp(
/** Painter on Windows, ImageVector on macOS/Linux */
@ExperimentalTrayAppApi
@Composable
-fun ApplicationScope.TrayApp(
+fun NucleusApplicationScope.TrayApp(
windowsIcon: Painter,
macLinuxIcon: ImageVector,
tint: Color? = null,
@@ -241,7 +240,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart: Boolean = false,
enterTransition: EnterTransition = defaultTrayAppEnterTransition,
exitTransition: ExitTransition = defaultTrayAppExitTransition,
- transparent: Boolean = true,
windowsTitle: String = "",
windowIcon: Painter? = null,
undecorated: Boolean = true,
@@ -251,7 +249,7 @@ fun ApplicationScope.TrayApp(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
menu: (TrayMenuBuilder.() -> Unit)? = null,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
if (Platform.Current == Platform.Windows) {
TrayApp(
@@ -263,7 +261,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
@@ -286,7 +283,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
@@ -303,7 +299,7 @@ fun ApplicationScope.TrayApp(
@ExperimentalTrayAppApi
@Composable
-fun ApplicationScope.TrayApp(
+fun NucleusApplicationScope.TrayApp(
icon: DrawableResource,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -312,7 +308,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart: Boolean = false,
enterTransition: EnterTransition = defaultTrayAppEnterTransition,
exitTransition: ExitTransition = defaultTrayAppExitTransition,
- transparent: Boolean = true,
windowsTitle: String = "",
windowIcon: Painter? = null,
undecorated: Boolean = true,
@@ -322,7 +317,7 @@ fun ApplicationScope.TrayApp(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
menu: (TrayMenuBuilder.() -> Unit)? = null,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
TrayApp(
icon = painterResource(icon),
@@ -333,13 +328,14 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
resizable = resizable,
horizontalOffset = horizontalOffset,
verticalOffset = verticalOffset,
+ onPreviewKeyEvent = onPreviewKeyEvent,
+ onKeyEvent = onKeyEvent,
menu = menu,
content = content,
)
@@ -347,7 +343,7 @@ fun ApplicationScope.TrayApp(
@ExperimentalTrayAppApi
@Composable
-fun ApplicationScope.TrayApp(
+fun NucleusApplicationScope.TrayApp(
windowsIcon: DrawableResource,
macLinuxIcon: ImageVector,
tint: Color? = null,
@@ -358,7 +354,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart: Boolean = false,
enterTransition: EnterTransition = defaultTrayAppEnterTransition,
exitTransition: ExitTransition = defaultTrayAppExitTransition,
- transparent: Boolean = true,
windowsTitle: String = "",
windowIcon: Painter? = null,
undecorated: Boolean = true,
@@ -368,7 +363,7 @@ fun ApplicationScope.TrayApp(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
menu: (TrayMenuBuilder.() -> Unit)? = null,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
if (Platform.Current == Platform.Windows) {
TrayApp(
@@ -380,7 +375,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
@@ -403,7 +397,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart = visibleOnStart,
enterTransition = enterTransition,
exitTransition = exitTransition,
- transparent = transparent,
windowsTitle = windowsTitle,
windowIcon = windowIcon,
undecorated = undecorated,
@@ -418,11 +411,22 @@ fun ApplicationScope.TrayApp(
}
}
-// --------------------- Core router ---------------------
-
+// --------------------- Core implementation (Tao backend only) ---------------------
+
+/**
+ * Tray icon + anchored popup. Runs exclusively on the Nucleus Tao backend —
+ * call it inside `nucleusApplication(backend = NucleusBackend.Tao)` (or
+ * `Auto` with `decorated-window-tao` on the classpath).
+ *
+ * On Windows + macOS the popup body is hosted in a standalone per-pixel
+ * transparent, non-activating native panel — no backing window exists anywhere
+ * (nothing in the taskbar/Dock, Alt-Tab or the Start task view). On Linux the
+ * popup is a regular undecorated Tao window (opaque): no cross-WM transparency
+ * equivalent exists.
+ */
@ExperimentalTrayAppApi
@Composable
-fun ApplicationScope.TrayApp(
+fun NucleusApplicationScope.TrayApp(
iconContent: @Composable () -> Unit,
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
tooltip: String,
@@ -431,7 +435,6 @@ fun ApplicationScope.TrayApp(
visibleOnStart: Boolean = false,
enterTransition: EnterTransition = defaultTrayAppEnterTransition,
exitTransition: ExitTransition = defaultTrayAppExitTransition,
- transparent: Boolean = true,
windowsTitle: String = "",
windowIcon: Painter? = null,
undecorated: Boolean = true,
@@ -441,61 +444,43 @@ fun ApplicationScope.TrayApp(
onPreviewKeyEvent: (KeyEvent) -> Boolean = { false },
onKeyEvent: (KeyEvent) -> Boolean = { false },
menu: (TrayMenuBuilder.() -> Unit)? = null,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
- when (Platform.Current) {
- Platform.Linux ->
- TrayAppImplLinux(
- iconContent,
- iconRenderProperties,
- tooltip,
- state,
- windowSize,
- visibleOnStart,
- enterTransition,
- exitTransition,
- transparent,
- windowsTitle,
- windowIcon,
- undecorated,
- resizable,
- horizontalOffset,
- verticalOffset,
- onPreviewKeyEvent,
- onKeyEvent,
- menu,
- content,
- )
+ require(backend == NucleusBackend.Tao) {
+ "TrayApp requires the Tao window backend. Launch with " +
+ "nucleusApplication(backend = NucleusBackend.Tao) and ship the " +
+ "nucleus.decorated-window-tao module."
+ }
- else ->
- TrayAppImplOriginal(
- iconContent,
- iconRenderProperties,
- tooltip,
- state,
- windowSize,
- visibleOnStart,
- enterTransition,
- exitTransition,
- transparent,
- windowsTitle,
- windowIcon,
- undecorated,
- resizable,
- horizontalOffset,
- verticalOffset,
- onPreviewKeyEvent,
- onKeyEvent,
- menu,
- content,
- )
+ // Linux gates on runtime availability: the panel is a raw X11 window,
+ // reachable through XWayland on Wayland sessions but absent on rare
+ // Wayland-only setups — those fall back to the opaque window impl.
+ val panelAvailable =
+ remember {
+ Platform.Current == Platform.Windows ||
+ Platform.Current == Platform.MacOS ||
+ (Platform.Current == Platform.Linux && isTaoStandalonePopupAvailable())
+ }
+ if (panelAvailable) {
+ TrayAppImplPanel(
+ iconContent, iconRenderProperties, tooltip, state, windowSize, visibleOnStart,
+ enterTransition, exitTransition,
+ horizontalOffset, verticalOffset, onPreviewKeyEvent, onKeyEvent, menu, content,
+ )
+ } else {
+ TrayAppImplWindow(
+ iconContent, iconRenderProperties, tooltip, state, windowSize, visibleOnStart,
+ enterTransition, exitTransition, windowsTitle, windowIcon, undecorated, resizable,
+ horizontalOffset, verticalOffset, onPreviewKeyEvent, onKeyEvent, menu, content,
+ )
}
}
-// --------------------- Impl: macOS/Windows ---------------------
+// --------------------- Impl: standalone transparent panel (Windows + macOS) ---------------------
+@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
-private fun ApplicationScope.TrayAppImplOriginal(
+private fun TrayAppImplPanel(
iconContent: @Composable () -> Unit,
iconRenderProperties: IconRenderProperties,
tooltip: String,
@@ -504,18 +489,17 @@ private fun ApplicationScope.TrayAppImplOriginal(
visibleOnStart: Boolean,
enterTransition: EnterTransition,
exitTransition: ExitTransition,
- transparent: Boolean,
- windowsTitle: String,
- windowIcon: Painter?,
- undecorated: Boolean,
- resizable: Boolean,
horizontalOffset: Int,
verticalOffset: Int,
onPreviewKeyEvent: (KeyEvent) -> Boolean,
onKeyEvent: (KeyEvent) -> Boolean,
menu: (TrayMenuBuilder.() -> Unit)?,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
+ val toggleDebounceMs = 150L
+ val minVisibleDurationMs = 200L
+ val minHiddenDurationMs = 100L
+
val trayAppState =
state ?: rememberTrayAppState(
initialWindowSize = windowSize ?: DpSize(300.dp, 200.dp),
@@ -527,6 +511,8 @@ private fun ApplicationScope.TrayAppImplOriginal(
val dismissMode by trayAppState.dismissMode.collectAsState()
val tray = remember { NativeTray() }
+ val instanceKey = remember { tray.instanceKey() }
+
val isDark = isMenuBarInDarkMode()
val contentHash = ComposableIconUtils.calculateContentHash(iconRenderProperties, iconContent) + isDark.hashCode()
val pngIconPath =
@@ -535,149 +521,90 @@ private fun ApplicationScope.TrayAppImplOriginal(
}
val windowsIconPath =
remember(contentHash) {
- if (Platform.Current == Platform.Windows) {
- ComposableIconUtils.renderComposableToIcoFile(
- iconRenderProperties,
- iconContent,
- )
- } else {
- pngIconPath
- }
+ ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent)
}
val menuHash = MenuContentHash.calculateMenuHash(menu)
- var shouldShowWindow by remember { mutableStateOf(false) }
-
- var lastPrimaryActionAt by remember { mutableStateOf(0L) }
- val toggleDebounceMs = 150L
-
- var lastShownAt by remember { mutableStateOf(0L) }
- var lastHiddenAt by remember { mutableStateOf(0L) }
- val minVisibleDurationMs = 200L
- val minHiddenDurationMs = 100L
-
- var lastFocusLostAt by remember { mutableStateOf(0L) }
- var autoHideEnabledAt by remember { mutableStateOf(0L) }
+ // The panel is a standalone native surface; its composition (and the
+ // user's content state) survives hide/show.
+ var popupOnScreen by remember { mutableStateOf(false) }
+ var popupScreenPos by remember { mutableStateOf(WindowPosition.Absolute(0.dp, 0.dp)) }
// Position pre-computed at click time so the LaunchedEffect can use it immediately.
var pendingPosition by remember { mutableStateOf(null) }
- // Store window reference for macOS Space detection
- var windowRef by remember { mutableStateOf(null) }
-
- val dialogState = rememberDialogState(size = currentWindowSize)
- LaunchedEffect(currentWindowSize) { dialogState.size = currentWindowSize }
-
// Visibility controller for exit-finish observation; content will NOT be disposed.
val visibleState = remember { MutableTransitionState(false) }
- // Thread-safe timestamps for cross-thread communication (IO thread reads, EDT writes)
- val lastFocusLostAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
- val lastHiddenAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
- val lastShownAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
- val lastPrimaryActionAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
+ // Thread-safe timestamps: the tray primary action arrives on a native callback thread.
+ val lastDismissAtMs = remember { AtomicLong(0L) }
+ val lastHiddenAtMs = remember { AtomicLong(0L) }
+ val lastShownAtMs = remember { AtomicLong(0L) }
+ val lastPrimaryActionAtMs = remember { AtomicLong(0L) }
val requestHideExplicit: () -> Unit = {
val now = System.currentTimeMillis()
val sinceShow = now - lastShownAtMs.get()
- debugln {
- "[TrayApp] requestHideExplicit called, sinceShow=${sinceShow}ms, " +
- "thread=${Thread.currentThread().name}"
- }
+ debugln { "[TrayApp] requestHideExplicit called, sinceShow=${sinceShow}ms" }
if (sinceShow >= minVisibleDurationMs) {
trayAppState.hide()
lastHiddenAtMs.set(System.currentTimeMillis())
- lastHiddenAt = lastHiddenAtMs.get()
} else {
val wait = minVisibleDurationMs - sinceShow
CoroutineScope(Dispatchers.IO).launch {
delay(wait)
trayAppState.hide()
lastHiddenAtMs.set(System.currentTimeMillis())
- lastHiddenAt = lastHiddenAtMs.get()
}
}
}
- val internalPrimaryAction: () -> Unit = {
+ val internalPrimaryAction: () -> Unit = action@{
val now = System.currentTimeMillis()
- // Read directly from StateFlow for thread-safe cross-thread access
val isVisibleNow = trayAppState.isVisible.value
val timeSinceLastAction = now - lastPrimaryActionAtMs.get()
debugln {
"[TrayApp] primaryAction: isVisibleNow=$isVisibleNow, " +
- "isVisible(compose)=$isVisible, " +
- "timeSinceLastAction=${timeSinceLastAction}ms, " +
- "thread=${Thread.currentThread().name}"
+ "timeSinceLastAction=${timeSinceLastAction}ms"
}
- if (timeSinceLastAction >= toggleDebounceMs) {
- lastPrimaryActionAtMs.set(now)
- lastPrimaryActionAt = now
- if (isVisibleNow) {
- // On macOS, check if the window is on another Space
- if (Platform.Current == Platform.MacOS) {
- val onActiveSpace =
- runCatching {
- MacOSWindowManager().isFloatingWindowOnActiveSpace()
- }.getOrElse { true }
- debugln { "[TrayApp] primaryAction: onActiveSpace=$onActiveSpace" }
- if (!onActiveSpace) {
- // Window is on another Space → move it to current Space via native API
- debugln { "[TrayApp] primaryAction -> MOVE TO CURRENT SPACE" }
- invokeLater {
- runCatching { MacOSWindowManager().bringFloatingWindowToFront() }
- }
- } else {
- debugln { "[TrayApp] primaryAction -> HIDE" }
- requestHideExplicit()
- }
- } else {
- debugln { "[TrayApp] primaryAction -> HIDE" }
- requestHideExplicit()
- }
- } else {
- val hiddenAgo = now - lastHiddenAtMs.get()
- val focusLostAgo = now - lastFocusLostAtMs.get()
- debugln {
- "[TrayApp] primaryAction SHOW path: " +
- "hiddenAgo=${hiddenAgo}ms, focusLostAgo=${focusLostAgo}ms"
- }
- if (hiddenAgo >= minHiddenDurationMs) {
- if ((Platform.Current == Platform.Windows || Platform.Current == Platform.MacOS) && focusLostAgo < 150) {
- // ignore immediate re-show after focus loss on Windows/macOS
- debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (recent focus loss)" }
- } else {
- debugln { "[TrayApp] primaryAction -> SHOW" }
- // Pre-compute position at click time: the native status item
- // geometry is guaranteed to be available right now.
- runCatching {
- val widthPx = currentWindowSize.width.value.toInt()
- val heightPx = currentWindowSize.height.value.toInt()
- pendingPosition =
- getTrayWindowPositionForInstance(
- tray.instanceKey(), widthPx, heightPx, horizontalOffset, verticalOffset,
- )
- }
- trayAppState.show()
- }
- } else {
- debugln {
- "[TrayApp] primaryAction -> SHOW BLOCKED " +
- "(too soon after hide: ${hiddenAgo}ms < ${minHiddenDurationMs}ms)"
- }
- }
- }
+ if (timeSinceLastAction < toggleDebounceMs) {
+ debugln { "[TrayApp] primaryAction -> DEBOUNCED" }
+ return@action
+ }
+ lastPrimaryActionAtMs.set(now)
+ if (isVisibleNow) {
+ debugln { "[TrayApp] primaryAction -> HIDE" }
+ requestHideExplicit()
} else {
- debugln { "[TrayApp] primaryAction -> DEBOUNCED (${timeSinceLastAction}ms < ${toggleDebounceMs}ms)" }
+ val hiddenAgo = now - lastHiddenAtMs.get()
+ val dismissedAgo = now - lastDismissAtMs.get()
+ if (hiddenAgo < minHiddenDurationMs) {
+ debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (too soon after hide)" }
+ return@action
+ }
+ // A click on the tray icon while the popup is visible first lands in
+ // the outside-click monitor (which dismisses), then reaches this
+ // callback — without this guard the popup would instantly re-open.
+ if (dismissedAgo < 300) {
+ debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (recent outside-click dismiss)" }
+ return@action
+ }
+ debugln { "[TrayApp] primaryAction -> SHOW" }
+ runCatching {
+ val widthPx = currentWindowSize.width.value.toInt()
+ val heightPx = currentWindowSize.height.value.toInt()
+ pendingPosition =
+ getTrayWindowPositionForInstance(
+ instanceKey, widthPx, heightPx, horizontalOffset, verticalOffset,
+ )
+ }
+ trayAppState.show()
}
}
LaunchedEffect(isVisible) {
- // Drive transition state
- visibleState.targetState = isVisible
-
if (isVisible) {
- if (!shouldShowWindow) {
+ if (!popupOnScreen) {
val preComputed = pendingPosition
pendingPosition = null
@@ -686,224 +613,118 @@ private fun ApplicationScope.TrayAppImplOriginal(
debugln { "[TrayApp] Using preComputed position: $preComputed" }
preComputed
} else {
- // Fallback: poll for position (e.g. initiallyVisible or programmatic show)
- // Wait for Windows to finish reorganizing tray icons after adding a new one.
- // Windows moves icons around after creation, so we need to wait and re-poll.
+ // Fallback: poll for position (e.g. visibleOnStart or programmatic show).
debugln { "[TrayApp] No preComputed position, waiting for tray to stabilize..." }
- delay(400) // Give Windows time to reorganize tray icons
-
val widthPx = currentWindowSize.width.value.toInt()
val heightPx = currentWindowSize.height.value.toInt()
- // On Windows, force a fresh position capture via the native API
+ var pos: WindowPosition = WindowPosition.PlatformDefault
if (Platform.Current == Platform.Windows) {
+ // Windows moves tray icons around after creation, so wait for the
+ // shell to settle, refresh the cached icon rect, then re-poll.
+ delay(400)
debugln { "[TrayApp] Re-capturing tray position from native API..." }
- WindowsTrayInitializer.refreshPosition(tray.instanceKey())
- delay(50) // Let the position update propagate
+ WindowsTrayInitializer.refreshPosition(instanceKey)
+ delay(50)
}
-
- var pos: WindowPosition = WindowPosition.PlatformDefault
+ // macOS positions precisely up front (status item rect), so it skips
+ // the stabilization wait and resolves on the first poll below.
val deadline = System.currentTimeMillis() + 3000
while (pos is WindowPosition.PlatformDefault && System.currentTimeMillis() < deadline) {
pos =
getTrayWindowPositionForInstance(
- tray.instanceKey(), widthPx, heightPx, horizontalOffset, verticalOffset,
+ instanceKey, widthPx, heightPx, horizontalOffset, verticalOffset,
)
debugln { "[TrayApp] Polled position: $pos" }
if (pos is WindowPosition.PlatformDefault) delay(250)
}
+ if (pos is WindowPosition.PlatformDefault) {
+ // Tray never became ready within the deadline — fall back
+ // to the corner heuristic rather than showing at (0,0).
+ pos = getTrayWindowPosition(widthPx, heightPx, horizontalOffset, verticalOffset)
+ debugln { "[TrayApp] Poll deadline expired, corner fallback: $pos" }
+ }
pos
}
- debugln { "[TrayApp] Setting dialogState.position = $position" }
- dialogState.position = position
-
- // Wait for Compose to apply the position before showing the window
- delay(30)
-
- if (Platform.Current == Platform.Windows) {
- autoHideEnabledAt = System.currentTimeMillis() + 1000
+ if (position is WindowPosition.Absolute) {
+ popupScreenPos = position
}
- debugln { "[TrayApp] Now showing window" }
- shouldShowWindow = true
- val showTime = System.currentTimeMillis()
- lastShownAt = showTime
- lastShownAtMs.set(showTime)
+ popupOnScreen = true
+ // Let the panel render a first frame at the new position
+ // before the enter animation starts.
+ delay(30)
+ visibleState.targetState = true
+ debugln { "[TrayApp] Popup now on screen at $position" }
+ lastShownAtMs.set(System.currentTimeMillis())
}
} else {
- // Wait for exit animation to finish, then actually hide the window
- if (shouldShowWindow) {
+ if (popupOnScreen) {
+ // Play the exit animation, then hide the panel.
+ visibleState.targetState = false
snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it }
- shouldShowWindow = false
- val hideTime = System.currentTimeMillis()
- lastHiddenAt = hideTime
- lastHiddenAtMs.set(hideTime)
+ popupOnScreen = false
+ lastHiddenAtMs.set(System.currentTimeMillis())
}
}
}
- LaunchedEffect(pngIconPath, windowsIconPath, tooltip, internalPrimaryAction, menu, contentHash, menuHash) {
- tray.update(pngIconPath, windowsIconPath, tooltip, internalPrimaryAction, menu)
+ // Re-anchor while visible when the popup size or offsets change.
+ LaunchedEffect(currentWindowSize, horizontalOffset, verticalOffset) {
+ if (popupOnScreen) {
+ val w = currentWindowSize.width.value.toInt()
+ val h = currentWindowSize.height.value.toInt()
+ val pos = getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
+ if (pos is WindowPosition.Absolute) popupScreenPos = pos
+ }
}
- LaunchedEffect(Unit) {
- if (Platform.Current == Platform.MacOS) {
- WindowVisibilityMonitor.hasAnyVisibleWindows.collectLatest { hasVisible ->
- runCatching {
- val manager = MacOSWindowManager()
- if (hasVisible) manager.showInDock() else manager.hideFromDock()
- }
- }
- }
+ LaunchedEffect(pngIconPath, windowsIconPath, tooltip, internalPrimaryAction, menu, contentHash, menuHash) {
+ tray.update(pngIconPath, windowsIconPath, tooltip, internalPrimaryAction, menu)
}
DisposableEffect(Unit) { onDispose { tray.dispose() } }
- DialogWindow(
- onCloseRequest = { requestHideExplicit() },
- title = windowsTitle,
- icon = windowIcon,
- undecorated = undecorated,
- resizable = resizable,
+ TaoStandalonePopup(
+ visible = popupOnScreen,
+ position = popupScreenPos,
+ size = currentWindowSize,
focusable = true,
- alwaysOnTop = true,
- transparent = transparent,
- visible = shouldShowWindow,
- state = dialogState,
+ onOutsideClick = {
+ // The monitor is process-wide: ignore clicks while already hidden.
+ if (trayAppState.isVisible.value) {
+ lastDismissAtMs.set(System.currentTimeMillis())
+ debugln { "[TrayApp] outside click dismiss, dismissMode=$dismissMode" }
+ if (dismissMode == TrayWindowDismissMode.AUTO) requestHideExplicit()
+ }
+ },
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
) {
- DisposableEffect(shouldShowWindow, dismissMode) {
- if (!shouldShowWindow) return@DisposableEffect onDispose { }
-
- // Store window reference for Space detection on macOS
- windowRef = window
-
- try {
- window.name = WindowVisibilityMonitor.TRAY_DIALOG_NAME
- } catch (_: Throwable) {
- }
- runCatching { WindowVisibilityMonitor.recompute() }
-
- debugln {
- "[TrayApp] Window shown at native position: x=${window.x}, y=${window.y}, " +
- "dialogState.position=${dialogState.position}"
- }
-
- invokeLater {
- // Move the popup to the current Space before bringing it to front (macOS)
- if (Platform.Current == Platform.MacOS) {
- debugln { "[TrayApp] Setting up macOS Space behavior on window..." }
- val nativeResult = runCatching { MacNativeBridge.nativeSetMoveToActiveSpace() }
- debugln {
- "[TrayApp] nativeSetMoveToActiveSpace: " +
- "${nativeResult.exceptionOrNull()?.message ?: "OK"}"
- }
- val moveResult = runCatching { MacOSWindowManager().setMoveToActiveSpace(window) }
- debugln {
- "[TrayApp] setMoveToActiveSpace " +
- "result=${moveResult.getOrNull()}, " +
- "error=${moveResult.exceptionOrNull()?.message}"
- }
- }
- debugln { "[TrayApp] After invokeLater: window at x=${window.x}, y=${window.y}" }
- runCatching {
- window.toFront()
- window.requestFocus()
- window.requestFocusInWindow()
- }
- }
-
- val focusListener =
- object : WindowFocusListener {
- override fun windowGainedFocus(e: WindowEvent?) = Unit
-
- override fun windowLostFocus(e: WindowEvent?) {
- val now = System.currentTimeMillis()
- lastFocusLostAtMs.set(now)
- lastFocusLostAt = now
- debugln {
- "[TrayApp] windowLostFocus at $now, " +
- "dismissMode=$dismissMode, " +
- "thread=${Thread.currentThread().name}"
- }
- if (Platform.Current == Platform.Windows && now < autoHideEnabledAt) return
- // On macOS, don't auto-hide if the window is not on the active Space.
- // A Space switch caused the focus loss — let the primary action handle it.
- if (Platform.Current == Platform.MacOS) {
- val onActiveSpace =
- runCatching {
- MacOSWindowManager().isFloatingWindowOnActiveSpace()
- }.getOrElse { true }
- debugln { "[TrayApp] windowLostFocus: onActiveSpace=$onActiveSpace" }
- if (!onActiveSpace) return
- }
- if (dismissMode == TrayWindowDismissMode.AUTO) requestHideExplicit()
- }
- }
-
- val macWatcher =
- if (dismissMode == TrayWindowDismissMode.AUTO && Platform.Current == Platform.MacOS) {
- MacOutsideClickWatcher(
- windowSupplier = { window },
- onOutsideClick = { invokeLater { requestHideExplicit() } },
- ).also { it.start() }
- } else {
- null
- }
-
- val linuxWatcher =
- if (dismissMode == TrayWindowDismissMode.AUTO && Platform.Current == Platform.Linux) {
- LinuxOutsideClickWatcher(
- windowSupplier = { window },
- onOutsideClick = { invokeLater { requestHideExplicit() } },
- ).also { it.start() }
- } else {
- null
- }
-
- val windowsWatcher =
- if (dismissMode == TrayWindowDismissMode.AUTO && Platform.Current == Platform.Windows) {
- WindowsOutsideClickWatcher(
- windowSupplier = { window },
- onOutsideClick = { invokeLater { requestHideExplicit() } },
- ).also { it.start() }
- } else {
- null
- }
-
- window.addWindowFocusListener(focusListener)
- onDispose {
- window.removeWindowFocusListener(focusListener)
- macWatcher?.stop()
- linuxWatcher?.stop()
- windowsWatcher?.stop()
- runCatching { WindowVisibilityMonitor.recompute() }
- windowRef = null
+ Box(Modifier.fillMaxSize()) {
+ // ---- Persistent (non-disposing) visibility wrapper ----
+ PersistentAnimatedVisibility(
+ visibleState = visibleState,
+ enter = enterTransition,
+ exit = exitTransition,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .graphicsLayer()
+ // Child can still opt into its own per-node enter/exit if desired:
+ .animateEnterExit(),
+ ) { content() }
}
}
-
- // ---- Persistent (non-disposing) visibility wrapper ----
- PersistentAnimatedVisibility(
- visibleState = visibleState,
- enter = enterTransition,
- exit = exitTransition,
- ) {
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .graphicsLayer()
- // Child can still opt into its own per-node enter/exit if desired:
- .animateEnterExit(),
- ) { content() }
- }
}
}
-// --------------------- Impl: Linux ---------------------
+// --------------------- Impl: opaque window (Linux) ---------------------
+@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
-private fun ApplicationScope.TrayAppImplLinux(
+private fun NucleusApplicationScope.TrayAppImplWindow(
iconContent: @Composable () -> Unit,
iconRenderProperties: IconRenderProperties,
tooltip: String,
@@ -912,7 +733,6 @@ private fun ApplicationScope.TrayAppImplLinux(
visibleOnStart: Boolean,
enterTransition: EnterTransition,
exitTransition: ExitTransition,
- transparent: Boolean,
windowsTitle: String,
windowIcon: Painter?,
undecorated: Boolean,
@@ -922,8 +742,12 @@ private fun ApplicationScope.TrayAppImplLinux(
onPreviewKeyEvent: (KeyEvent) -> Boolean,
onKeyEvent: (KeyEvent) -> Boolean,
menu: (TrayMenuBuilder.() -> Unit)?,
- content: @Composable DialogWindowScope.() -> Unit,
+ content: @Composable () -> Unit,
) {
+ val toggleDebounceMs = 280L
+ val minVisibleDurationMs = 350L
+ val minHiddenDurationMs = 250L
+
val trayAppState =
state ?: rememberTrayAppState(
initialWindowSize = windowSize ?: DpSize(300.dp, 200.dp),
@@ -940,75 +764,96 @@ private fun ApplicationScope.TrayAppImplLinux(
val isDark = isMenuBarInDarkMode()
val contentHash = ComposableIconUtils.calculateContentHash(iconRenderProperties, iconContent) + isDark.hashCode()
val pngIconPath =
- remember(contentHash) { ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) }
+ remember(contentHash) {
+ ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent)
+ }
val windowsIconPath = pngIconPath
val menuHash = MenuContentHash.calculateMenuHash(menu)
var shouldShowWindow by remember { mutableStateOf(false) }
- var lastPrimaryActionAt by remember { mutableStateOf(0L) }
- val toggleDebounceMs = 280L
- var lastShownAt by remember { mutableStateOf(0L) }
- var lastHiddenAt by remember { mutableStateOf(0L) }
- val minVisibleDurationMs = 350L
- val minHiddenDurationMs = 250L
- val initialPositionForFirstFrame =
- remember(instanceKey, currentWindowSize, horizontalOffset, verticalOffset) {
- val w = currentWindowSize.width.value.toInt()
- val h = currentWindowSize.height.value.toInt()
- getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
- }
+ // Position pre-computed at click time so the LaunchedEffect can use it immediately.
+ var pendingPosition by remember { mutableStateOf(null) }
- val dialogState = rememberDialogState(position = initialPositionForFirstFrame, size = currentWindowSize)
- LaunchedEffect(currentWindowSize) { dialogState.size = currentWindowSize }
+ val windowState =
+ rememberWindowState(
+ size = currentWindowSize,
+ position =
+ getTrayWindowPositionForInstance(
+ instanceKey,
+ currentWindowSize.width.value.toInt(),
+ currentWindowSize.height.value.toInt(),
+ horizontalOffset,
+ verticalOffset,
+ ),
+ )
+ LaunchedEffect(currentWindowSize) { windowState.size = currentWindowSize }
- // Visibility controller for exit-finish detection; content will NOT be disposed.
+ // Visibility controller for exit-finish observation; content will NOT be disposed.
val visibleState = remember { MutableTransitionState(false) }
+ val lastHiddenAtMs = remember { AtomicLong(0L) }
+ val lastShownAtMs = remember { AtomicLong(0L) }
+ val lastPrimaryActionAtMs = remember { AtomicLong(0L) }
+
val requestHideExplicit: () -> Unit = {
val now = System.currentTimeMillis()
- val sinceShow = now - lastShownAt
+ val sinceShow = now - lastShownAtMs.get()
if (sinceShow >= minVisibleDurationMs) {
trayAppState.hide()
- lastHiddenAt = now
+ lastHiddenAtMs.set(System.currentTimeMillis())
} else {
- val wait = (minVisibleDurationMs - sinceShow).coerceAtLeast(0)
+ val wait = minVisibleDurationMs - sinceShow
CoroutineScope(Dispatchers.IO).launch {
delay(wait)
trayAppState.hide()
- lastHiddenAt = System.currentTimeMillis()
+ lastHiddenAtMs.set(System.currentTimeMillis())
}
}
}
val internalPrimaryAction: () -> Unit = action@{
val now = System.currentTimeMillis()
- if (now - lastPrimaryActionAt < toggleDebounceMs) return@action
- lastPrimaryActionAt = now
- if (isVisible) {
+ if (now - lastPrimaryActionAtMs.get() < toggleDebounceMs) return@action
+ lastPrimaryActionAtMs.set(now)
+ if (trayAppState.isVisible.value) {
requestHideExplicit()
- } else if (now - lastHiddenAt >= minHiddenDurationMs) {
- val w = currentWindowSize.width.value.toInt()
- val h = currentWindowSize.height.value.toInt()
- dialogState.position = getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
+ } else if (now - lastHiddenAtMs.get() >= minHiddenDurationMs) {
+ runCatching {
+ val w = currentWindowSize.width.value.toInt()
+ val h = currentWindowSize.height.value.toInt()
+ pendingPosition =
+ getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
+ }
trayAppState.show()
}
}
LaunchedEffect(isVisible) {
- // Drive transition state
visibleState.targetState = isVisible
-
if (isVisible) {
if (!shouldShowWindow) {
+ val preComputed = pendingPosition
+ pendingPosition = null
+ val position =
+ preComputed
+ ?: getTrayWindowPositionForInstance(
+ instanceKey,
+ currentWindowSize.width.value.toInt(),
+ currentWindowSize.height.value.toInt(),
+ horizontalOffset,
+ verticalOffset,
+ )
+ windowState.position = position
+ delay(30)
shouldShowWindow = true
- lastShownAt = System.currentTimeMillis()
+ lastShownAtMs.set(System.currentTimeMillis())
}
} else {
if (shouldShowWindow) {
snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it }
shouldShowWindow = false
- lastHiddenAt = System.currentTimeMillis()
+ lastHiddenAtMs.set(System.currentTimeMillis())
}
}
}
@@ -1018,7 +863,8 @@ private fun ApplicationScope.TrayAppImplLinux(
if (shouldShowWindow) {
val w = currentWindowSize.width.value.toInt()
val h = currentWindowSize.height.value.toInt()
- dialogState.position = getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
+ windowState.position =
+ getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
}
}
@@ -1028,51 +874,50 @@ private fun ApplicationScope.TrayAppImplLinux(
DisposableEffect(Unit) { onDispose { tray.dispose() } }
- DialogWindow(
+ DecoratedWindow(
onCloseRequest = { requestHideExplicit() },
+ state = windowState,
+ visible = shouldShowWindow,
title = windowsTitle,
icon = windowIcon,
- undecorated = undecorated,
resizable = resizable,
- focusable = shouldShowWindow,
+ focusable = true,
alwaysOnTop = true,
- transparent = transparent,
- visible = shouldShowWindow,
- state = dialogState,
+ undecorated = undecorated,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
) {
- DisposableEffect(shouldShowWindow, dismissMode) {
- if (!shouldShowWindow) return@DisposableEffect onDispose { }
+ val window = nucleusWindow
+ // Give the position code access to the popup's TaoWindow (needed by
+ // the GDK-backed work-area queries on Linux).
+ DisposableEffect(window) {
+ TrayScreenGeometry.taoWindowProvider = { window.unsafe.taoWindow }
+ onDispose { TrayScreenGeometry.taoWindowProvider = null }
+ }
+
+ // On show: raise + focus so focus-loss dismissal works.
+ LaunchedEffect(shouldShowWindow) {
+ if (!shouldShowWindow) return@LaunchedEffect
runCatching {
- val w = currentWindowSize.width.value.toInt()
- val h = currentWindowSize.height.value.toInt()
- dialogState.position =
- getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
+ window.toFront()
+ window.requestFocus()
}
+ }
- val linuxWatcher =
- if (dismissMode == TrayWindowDismissMode.AUTO) {
- LinuxOutsideClickWatcher(
- windowSupplier = { window },
- onOutsideClick = { invokeLater { requestHideExplicit() } },
- ).also { it.start() }
- } else {
- null
+ // Auto-hide on focus loss (edge-triggered: only after focus was gained).
+ LaunchedEffect(Unit) {
+ var hadFocus = false
+ window.focusFlow.collect { focused ->
+ if (focused) {
+ hadFocus = true
+ return@collect
}
-
- window.addWindowFocusListener(
- object : WindowFocusListener {
- override fun windowGainedFocus(e: WindowEvent?) = Unit
-
- override fun windowLostFocus(e: WindowEvent?) {
- if (dismissMode == TrayWindowDismissMode.AUTO) requestHideExplicit()
- }
- },
- )
-
- onDispose { linuxWatcher?.stop() }
+ if (!hadFocus) return@collect
+ hadFocus = false
+ if (!shouldShowWindow) return@collect
+ if (dismissMode == TrayWindowDismissMode.AUTO) requestHideExplicit()
+ }
}
// ---- Persistent (non-disposing) visibility wrapper ----
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/AwtTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/AwtTrayInitializer.kt
deleted file mode 100644
index 37a0c22c..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/AwtTrayInitializer.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package dev.nucleusframework.composenativetray.tray.impl
-
-import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder
-import dev.nucleusframework.composenativetray.menu.impl.AwtTrayMenuBuilderImpl
-import dev.nucleusframework.core.runtime.Platform
-import java.awt.PopupMenu
-import java.awt.SystemTray
-import java.awt.Toolkit
-import java.awt.TrayIcon
-import java.awt.event.MouseAdapter
-import java.awt.event.MouseEvent
-
-object AwtTrayInitializer {
- // Stores the reference to the current TrayIcon
- private var trayIcon: TrayIcon? = null
-
- fun isSupported(): Boolean = SystemTray.isSupported()
-
- /**
- * Initializes the system tray with the specified parameters.
- *
- * @param iconPath Path to the tray icon.
- * @param tooltip Tooltip text for the icon.
- * @param onLeftClick Action to execute on left-click on the icon.
- * @param menuContent Content of the tray menu.
- * @throws IllegalStateException If the system does not support the tray.
- */
- fun initialize(
- iconPath: String,
- tooltip: String,
- onLeftClick: (() -> Unit)?,
- menuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- if (!isSupported()) {
- throw IllegalStateException("System tray is not supported.")
- }
-
- // If a trayIcon already exists, remove it before creating a new one
- dispose()
-
- val systemTray = SystemTray.getSystemTray()
- val popupMenu = PopupMenu()
-
- // Create the tray icon
- val trayImage = Toolkit.getDefaultToolkit().getImage(iconPath)
-
- val newTrayIcon =
- TrayIcon(trayImage, tooltip, popupMenu).apply {
- isImageAutoSize = true
-
- // On macOS, we cannot easily override the default left-click behavior
- // when a popup menu is attached. We have two options:
- // 1. Accept that left-click opens the menu (macOS convention)
- // 2. Use a more complex native implementation via JNA
-
- if (Platform.Current == Platform.MacOS) {
- // For macOS, add primary action as the first menu item if both menu and action exist
- if (onLeftClick != null && menuContent != null) {
- // The primary action will be added as the first menu item
- // This is handled in the menu building phase
- }
- } else {
- // For other platforms, handle left-click normally
- onLeftClick?.let { clickAction ->
- addMouseListener(
- object : MouseAdapter() {
- override fun mouseClicked(e: MouseEvent) {
- if (e.button == MouseEvent.BUTTON1) {
- clickAction()
- }
- }
- },
- )
- }
- }
- }
-
- // Add the menu content if specified
- menuContent?.let {
- // For macOS, prepend the primary action to the menu if it exists
- if (Platform.Current == Platform.MacOS && onLeftClick != null) {
- val menuBuilder = AwtTrayMenuBuilderImpl(popupMenu, newTrayIcon)
- menuBuilder.Item("Open", true) { onLeftClick.invoke() }
- menuBuilder.Divider()
- menuBuilder.apply(it)
- } else {
- AwtTrayMenuBuilderImpl(popupMenu, newTrayIcon).apply(it)
- }
- }
-
- // Add the icon to the system tray
- systemTray.add(newTrayIcon)
-
- // Store the reference for future use
- trayIcon = newTrayIcon
- }
-
- fun update(
- iconPath: String,
- tooltip: String,
- onLeftClick: (() -> Unit)?,
- menuContent: (TrayMenuBuilder.() -> Unit)?,
- ) {
- dispose()
- initialize(iconPath, tooltip, onLeftClick, menuContent)
- }
-
- /**
- * Disposes of the current tray icon, if it exists.
- */
- fun dispose() {
- trayIcon?.let { icon ->
- SystemTray.getSystemTray().remove(icon)
- trayIcon = null
- }
- }
-}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt
index 6d378a70..dbe343e5 100644
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt
@@ -7,9 +7,6 @@ import dev.nucleusframework.composenativetray.lib.windows.WindowsNativeBridge
import dev.nucleusframework.composenativetray.tray.impl.MacTrayInitializer
import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment
import dev.nucleusframework.core.runtime.Platform
-import java.awt.GraphicsEnvironment
-import java.awt.Rectangle
-import java.awt.Toolkit
import java.io.File
import java.util.Collections
import java.util.Properties
@@ -81,29 +78,15 @@ internal object TrayClickTracker {
}
/**
- * Get logical screen size (DPI-independent on Windows).
- * On Windows, Toolkit.getDefaultToolkit().screenSize returns logical coordinates.
- */
-private fun getLogicalScreenSize(): java.awt.Dimension {
- return Toolkit.getDefaultToolkit().screenSize
-}
-
-/**
- * Returns the bounds of the screen that contains the given point.
- * Falls back to the primary screen if the point is not on any screen.
+ * Returns the bounds of the screen containing the given point. The Tao bridge
+ * only exposes the primary monitor's work area, so multi-monitor setups
+ * resolve to the primary work area (the tray lives there on all platforms).
*/
+@Suppress("UNUSED_PARAMETER")
private fun getScreenBoundsAt(
x: Int,
y: Int,
-): Rectangle {
- val ge = GraphicsEnvironment.getLocalGraphicsEnvironment()
- for (gd in ge.screenDevices) {
- val bounds = gd.defaultConfiguration.bounds
- if (bounds.contains(x, y)) return bounds
- }
- val primary = Toolkit.getDefaultToolkit().screenSize
- return Rectangle(0, 0, primary.width, primary.height)
-}
+): ScreenRect = TrayScreenGeometry.workAreaLogical()
internal fun convertPositionToCorner(
x: Int,
@@ -285,7 +268,7 @@ fun getTrayWindowPosition(
horizontalOffset: Int = 0,
verticalOffset: Int = 0,
): WindowPosition {
- val screenSize = Toolkit.getDefaultToolkit().screenSize
+ val screen = TrayScreenGeometry.workAreaLogical()
if (Platform.Current == Platform.Windows) {
val freshPos =
@@ -296,7 +279,7 @@ fun getTrayWindowPosition(
freshPos ?: run {
// No click yet (e.g., initiallyVisible = true). Synthesize one near the tray corner
val corner = getTrayPosition()
- val (sx, sy) = syntheticClickFromCorner(corner, screenSize.width, screenSize.height)
+ val (sx, sy) = syntheticClickFromCorner(corner, screen)
return calculateWindowPositionFromClick(
sx, sy, corner,
windowWidth, windowHeight,
@@ -349,24 +332,7 @@ fun getTrayWindowPosition(
}
}
- return when (getTrayPosition()) {
- TrayPosition.TOP_LEFT -> WindowPosition(x = (0 + horizontalOffset).dp, y = (0 + verticalOffset).dp)
- TrayPosition.TOP_RIGHT ->
- WindowPosition(
- x = (screenSize.width - windowWidth + horizontalOffset).dp,
- y = (0 + verticalOffset).dp,
- )
- TrayPosition.BOTTOM_LEFT ->
- WindowPosition(
- x = (0 + horizontalOffset).dp,
- y = (screenSize.height - windowHeight + verticalOffset).dp,
- )
- TrayPosition.BOTTOM_RIGHT ->
- WindowPosition(
- x = (screenSize.width - windowWidth + horizontalOffset).dp,
- y = (screenSize.height - windowHeight + verticalOffset).dp,
- )
- }
+ return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
}
/** Variante par instance (Windows multi-tray, mac precise) + offsets */
@@ -432,8 +398,13 @@ fun getTrayWindowPositionForInstance(
)
}
}
- // Fallback global
- getTrayWindowPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
+ // Tray not registered / status item not realised yet (initialisation
+ // is async: icon rendering + IO dispatch). Report PlatformDefault so
+ // callers with a poll loop (visibleOnStart path in TrayAppImplPanel)
+ // retry until the precise status-item rect is available, instead of
+ // latching a screen-corner fallback position.
+ debugln { "[TrayPosition] mac instance $instanceId not ready (handle=$trayHandle), PlatformDefault" }
+ WindowPosition.PlatformDefault
}
else -> getTrayWindowPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
}
@@ -441,7 +412,7 @@ fun getTrayWindowPositionForInstance(
/**
* Calcule la position (x,y) depuis un clic précis + applique les offsets et un clamp aux bords écran.
- * Uses the screen containing the click point for correct multi-monitor support.
+ * Coordinates are logical pixels within the primary monitor's work area.
*/
private fun calculateWindowPositionFromClick(
clickX: Int,
@@ -484,10 +455,13 @@ private fun calculateWindowPositionFromClick(
debugln { "[TrayPosition] Windows: final x=$x, y=$y" }
WindowPosition(x = x.dp, y = y.dp)
} else {
- val panelGuessPx = 28
-
+ // `sb` is the WORK AREA (Tao bridges): its top edge already sits below
+ // the macOS menu bar / Linux top panel, and its bottom edge above the
+ // dock/panel — anchor directly at the edge. (The pre-Tao AWT code used
+ // full-screen bounds and approximated the bar with a 28px guess; keeping
+ // that guess on top of the work area double-counts the bar.)
var x = clickX - (windowWidth / 2)
- val anchorY = if (isTop) sb.y + panelGuessPx else (sb.y + sb.height - panelGuessPx)
+ val anchorY = if (isTop) sb.y else (sb.y + sb.height)
var y = if (isTop) anchorY else anchorY - windowHeight
x += if (isRight) -horizontalOffset else horizontalOffset
@@ -514,15 +488,24 @@ private fun fallbackCornerPosition(
horizontalOffset: Int,
verticalOffset: Int,
): WindowPosition {
- val screen = Toolkit.getDefaultToolkit().screenSize
+ val screen = TrayScreenGeometry.workAreaLogical()
return when (getTrayPosition()) {
- TrayPosition.TOP_LEFT -> WindowPosition((0 + horizontalOffset).dp, (0 + verticalOffset).dp)
- TrayPosition.TOP_RIGHT -> WindowPosition((screen.width - w + horizontalOffset).dp, (0 + verticalOffset).dp)
- TrayPosition.BOTTOM_LEFT -> WindowPosition((0 + horizontalOffset).dp, (screen.height - h + verticalOffset).dp)
+ TrayPosition.TOP_LEFT ->
+ WindowPosition((screen.x + horizontalOffset).dp, (screen.y + verticalOffset).dp)
+ TrayPosition.TOP_RIGHT ->
+ WindowPosition(
+ (screen.x + screen.width - w + horizontalOffset).dp,
+ (screen.y + verticalOffset).dp,
+ )
+ TrayPosition.BOTTOM_LEFT ->
+ WindowPosition(
+ (screen.x + horizontalOffset).dp,
+ (screen.y + screen.height - h + verticalOffset).dp,
+ )
TrayPosition.BOTTOM_RIGHT ->
WindowPosition(
- (screen.width - w + horizontalOffset).dp,
- (screen.height - h + verticalOffset).dp,
+ (screen.x + screen.width - w + horizontalOffset).dp,
+ (screen.y + screen.height - h + verticalOffset).dp,
)
}
}
@@ -551,106 +534,27 @@ fun debugDeleteTrayPropertiesFiles() {
files.filter(File::exists).forEach { runCatching { it.delete() } }
}
-// DPI helpers / hit-test utilities unchanged (kept here for completeness)
private fun dpiAwareHalfIconOffset(): Int {
- return try {
- val dpi = Toolkit.getDefaultToolkit().screenResolution
- val scale = dpi / 96.0
- (15 * scale).roundToInt().coerceAtLeast(0)
- } catch (_: Throwable) {
- 15
- }
-}
-
-/**
- * Detects the Windows taskbar height based on DPI scaling.
- * Default taskbar: 40px at 100%, 48px at 125%, 60px at 150%, etc.
- */
-private fun getWindowsTaskbarHeight(): Int {
- return try {
- val dpi = Toolkit.getDefaultToolkit().screenResolution
- val scale = dpi / 96.0
- // Taskbar default height: 40px at 100% scaling
- (40 * scale).roundToInt().coerceIn(32, 72)
- } catch (_: Throwable) {
- 40
- }
-}
-
-/**
- * Gets the appropriate bar size (taskbar/menubar/panel) for the current OS.
- */
-private fun getSystemBarSize(): Int {
- return when (Platform.Current) {
- Platform.Windows -> getWindowsTaskbarHeight()
- Platform.MacOS -> 25 // macOS menu bar
- else -> 28 // Linux panel (GNOME/KDE average)
- }
+ val scale = TrayScreenGeometry.scale()
+ return (15 * scale).roundToInt().coerceAtLeast(0)
}
-internal fun isPointWithinMacStatusItem(
- px: Int,
- py: Int,
-): Boolean {
- if (Platform.Current != Platform.MacOS) return false
- val (ix, iy) = getStatusItemXYForMac()
- if (ix == 0 && iy == 0) return false
- val dpi = runCatching { Toolkit.getDefaultToolkit().screenResolution }.getOrDefault(96)
- val scale = dpi / 96.0
- val half = (14 * scale).roundToInt().coerceAtLeast(8)
- val left = ix - half
- val right = ix + half
- val top = iy - half
- val bottom = iy + half
- return px in left..right && py in top..bottom
-}
-
-internal fun isPointWithinLinuxStatusItem(
- px: Int,
- py: Int,
-): Boolean {
- if (Platform.Current != Platform.Linux) return false
- val click = TrayClickTracker.getLastClickPosition() ?: loadTrayClickPosition() ?: return false
- val (ix, iy) = click.x to click.y
- val baseIconSizeAt1x =
- when (LinuxDesktopEnvironment.Current) {
- LinuxDesktopEnvironment.KDE -> 22
- LinuxDesktopEnvironment.Gnome -> 24
- LinuxDesktopEnvironment.Cinnamon -> 24
- LinuxDesktopEnvironment.Mate -> 24
- LinuxDesktopEnvironment.XFCE -> 24
- else -> 24
- }
- val dpi = runCatching { Toolkit.getDefaultToolkit().screenResolution }.getOrDefault(96)
- val scale = (dpi / 96.0).coerceAtLeast(0.5)
- val half = (baseIconSizeAt1x * 0.5 * scale).toInt().coerceAtLeast(8)
- val fudge = (4 * scale).toInt().coerceAtLeast(2)
- val left = ix - half - fudge
- val right = ix + half + fudge
- val top = iy - half - fudge
- val bottom = iy + half + fudge
- return px in left..right && py in top..bottom
-}
-
-// TrayPosition.kt
-
private fun syntheticClickFromCorner(
corner: TrayPosition,
- screenW: Int,
- screenH: Int,
+ screen: ScreenRect,
): Pair {
val half = dpiAwareHalfIconOffset() // ~half icon in px, DPI-aware
val x =
if (corner == TrayPosition.TOP_RIGHT || corner == TrayPosition.BOTTOM_RIGHT) {
- screenW - half
+ screen.x + screen.width - half
} else {
- half
+ screen.x + half
}
val y =
if (corner == TrayPosition.BOTTOM_LEFT || corner == TrayPosition.BOTTOM_RIGHT) {
- screenH - half
+ screen.y + screen.height - half
} else {
- half
+ screen.y + half
}
return x to y
}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt
new file mode 100644
index 00000000..5fa96f2d
--- /dev/null
+++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt
@@ -0,0 +1,51 @@
+package dev.nucleusframework.composenativetray.utils
+
+import dev.nucleusframework.window.tao.TaoScreenGeometry
+import dev.nucleusframework.window.tao.TaoWindow
+
+internal data class ScreenRect(
+ val x: Int,
+ val y: Int,
+ val width: Int,
+ val height: Int,
+)
+
+/**
+ * Screen geometry backed by the Tao windowing backend (no AWT).
+ *
+ * On Linux the underlying GDK queries need a realized window: `TrayApp`
+ * registers its popup window through [taoWindowProvider]. Windows/macOS
+ * resolve the primary monitor directly.
+ */
+internal object TrayScreenGeometry {
+ @Volatile
+ var taoWindowProvider: (() -> TaoWindow?)? = null
+
+ private fun taoWindow(): TaoWindow? = taoWindowProvider?.invoke()
+
+ /** Primary monitor scale factor (1.0 on non-HiDPI displays). */
+ fun scale(): Float =
+ runCatching { TaoScreenGeometry.primaryMonitorScaleFactor(taoWindow()) }
+ .getOrDefault(1f)
+ .coerceAtLeast(1f)
+
+ /**
+ * Primary monitor work area (screen minus taskbar/menu bar/panel) in
+ * logical pixels, top-left origin. Falls back to a common resolution when
+ * the native bridge is unavailable so positioning degrades gracefully.
+ */
+ fun workAreaLogical(): ScreenRect {
+ val wa = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx(taoWindow()) }.getOrNull()
+ if (wa == null || wa.size != 4) return ScreenRect(0, 0, FALLBACK_WIDTH, FALLBACK_HEIGHT)
+ val s = scale()
+ return ScreenRect(
+ (wa[0] / s).toInt(),
+ (wa[1] / s).toInt(),
+ (wa[2] / s).toInt(),
+ (wa[3] / s).toInt(),
+ )
+ }
+
+ private const val FALLBACK_WIDTH = 1920
+ private const val FALLBACK_HEIGHT = 1080
+}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/WindowRaise.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/WindowRaise.kt
deleted file mode 100644
index aac204f9..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/WindowRaise.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package dev.nucleusframework.composenativetray.utils
-
-import androidx.compose.ui.window.WindowState
-import kotlinx.coroutines.delay
-import java.awt.Window
-
-object WindowRaise {
- fun raise(window: Window) {
- try {
- // Portable trick: temporarily make it topmost.
- window.isAlwaysOnTop = true
- window.toFront()
- window.requestFocus()
- window.requestFocusInWindow()
- } catch (_: Throwable) {
- // ignore
- }
- }
-
- fun unraise(window: Window) {
- try {
- window.isAlwaysOnTop = false
- } catch (_: Throwable) {
- }
- }
-
- /**
- * Convenience helper to raise a window, give the window manager a brief moment,
- * then revert the temporary always-on-top flag. Useful especially on Windows.
- */
- suspend fun forceFront(
- window: Window,
- windowState: WindowState,
- delayMs: Long = 250,
- ) {
- // Ensure it’s not minimized
- windowState.isMinimized = false
-
- // Raise first
- raise(window)
- try {
- // Give the WM a short moment to apply stacking/focus
- delay(delayMs)
- } catch (_: Throwable) {
- // ignore any coroutine cancellation or other issues
- }
- // Then revert
- unraise(window)
- }
-}
diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/WindowVisibilityMonitor.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/WindowVisibilityMonitor.kt
deleted file mode 100644
index 674e9179..00000000
--- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/WindowVisibilityMonitor.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package dev.nucleusframework.composenativetray.utils
-
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import java.awt.AWTEvent
-import java.awt.Frame
-import java.awt.Toolkit
-import java.awt.Window
-import java.awt.event.AWTEventListener
-import java.awt.event.ComponentEvent
-import java.awt.event.WindowEvent
-
-object WindowVisibilityMonitor {
- // Public marker name used to identify the tray popup dialog window so it can be excluded from checks
- const val TRAY_DIALOG_NAME: String = "ComposeTrayPopup"
-
- // Expose current visibility status as a StateFlow
- private val _hasVisible = MutableStateFlow(false)
- val hasAnyVisibleWindows: StateFlow = _hasVisible
-
- private val listener =
- AWTEventListener { event ->
- // React to window + component visibility changes
- val type = event.id
- when (type) {
- WindowEvent.WINDOW_OPENED,
- WindowEvent.WINDOW_CLOSED,
- WindowEvent.WINDOW_ICONIFIED,
- WindowEvent.WINDOW_DEICONIFIED,
- WindowEvent.WINDOW_STATE_CHANGED,
- ComponentEvent.COMPONENT_SHOWN,
- ComponentEvent.COMPONENT_HIDDEN,
- -> recompute()
- }
- }
-
- init {
- // Start listening as soon as the object is loaded
- val mask = AWTEvent.WINDOW_EVENT_MASK or AWTEvent.COMPONENT_EVENT_MASK
- Toolkit.getDefaultToolkit().addAWTEventListener(listener, mask)
- recompute()
- }
-
- private fun Window.isEffectivelyVisible(): Boolean {
- val isMinimized = (this is Frame) && ((extendedState and Frame.ICONIFIED) != 0)
- val hasSize = width > 0 && height > 0
- // Consider minimized windows as "effectively visible" for Dock visibility purposes
- return (isShowing && isVisible && isDisplayable && hasSize && opacity > 0f) || isMinimized
- }
-
- /** Recalculate and publish the current visibility state */
- fun recompute() {
- _hasVisible.value =
- Window.getWindows()
- .asSequence()
- .filter { it.name != TRAY_DIALOG_NAME }
- .any { it.isEffectivelyVisible() }
- }
-}