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() } - } -}