diff --git a/.gitignore b/.gitignore index be7b003..ca7a13e 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ bin/ .idea tray_position.properties + +linuxlib/build-x86-64 +linuxlibdbus/build-x86-64 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 02f107b..843c32c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -import com.vanniktech.maven.publish.SonatypeHost import org.jetbrains.dokka.gradle.DokkaTask plugins { @@ -38,11 +37,14 @@ kotlin { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) + implementation(compose.ui) implementation(libs.jna) implementation(libs.jna.platform) implementation(libs.kotlinx.coroutines.core) - implementation(libs.kmp.log) + implementation(libs.kotlinx.coroutines.swing) implementation(libs.platformtools.core) + implementation(libs.platformtools.darkmodedetector) + } } @@ -54,8 +56,20 @@ val buildWin: TaskProvider = tasks.register("buildNativeWin") { commandLine("cmd", "/c", "build.bat") } +val buildMac: TaskProvider = tasks.register("buildNativeMac") { + onlyIf { System.getProperty("os.name").startsWith("Mac") } + workingDir(rootDir.resolve("maclib")) + commandLine("sh", "build.sh") +} + +val buildLinux: TaskProvider = tasks.register("buildNativeLinux") { +// onlyIf { System.getProperty("os.name").toLowerCase().contains("linux") } +// workingDir(rootDir.resolve("linuxlibdbus")) +//// commandLine("./build.sh") +} + tasks.register("buildNativeLibraries") { - dependsOn(buildWin) + dependsOn(buildWin, buildLinux, buildMac) } mavenPublishing { @@ -95,7 +109,7 @@ mavenPublishing { } // Configure publishing to Maven Central - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + publishToMavenCentral() // Enable GPG signing for all publications diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index c1ff69d..0434e4c 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.gradle.api.tasks.JavaExec plugins { alias(libs.plugins.multiplatform) @@ -18,8 +19,10 @@ kotlin { implementation(compose.desktop.currentOs) implementation(compose.components.resources) implementation(compose.material3) - implementation("org.jetbrains.compose.material:material-icons-core:1.7.3") - implementation(libs.kmp.log) + implementation(compose.materialIconsExtended) + implementation(libs.kermit) + implementation(libs.platformtools.darkmodedetector) + } } } @@ -41,3 +44,22 @@ compose.desktop { } } } + +// Task to build native libraries and run the demo +tasks.register("buildAndRunDemo") { + // Depend on the buildNativeLibraries task from the root project + dependsOn(rootProject.tasks.named("buildNativeLibraries")) + + // This task doesn't do anything by itself, it just depends on buildNativeLibraries + // and will be followed by the run task + doLast { + println("Native libraries built successfully. Starting demo application...") + } + + // Make sure the run task is executed after this task + finalizedBy(tasks.named("run")) + + // Description for the task + description = "Builds the native libraries and then runs the demo application" + group = "application" +} diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/App.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/App.kt index a403a44..5bedf0c 100644 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/App.kt +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/App.kt @@ -1,12 +1,14 @@ package com.kdroid.composetray.demo import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode import kotlin.system.exitProcess @OptIn(ExperimentalMaterial3Api::class) @@ -19,14 +21,17 @@ fun App( onToggleChange: (Boolean, Boolean) -> Unit ) { var currentScreen by remember { mutableStateOf(Screen.Screen1) } + + // Automatically detect system theme + val isDarkTheme = isSystemInDarkMode() - // Material3 Theme + // Material3 Theme with dark mode support MaterialTheme( - colorScheme = lightColorScheme( - primary = MaterialTheme.colorScheme.primary, - secondary = MaterialTheme.colorScheme.secondary, - tertiary = MaterialTheme.colorScheme.tertiary - ) + colorScheme = if (isDarkTheme) { + darkColorScheme() + } else { + lightColorScheme() + } ) { Scaffold( topBar = { diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoAdaptivePositionWindows.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoAdaptivePositionWindows.kt index 15b2e7a..d7650c6 100644 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoAdaptivePositionWindows.kt +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoAdaptivePositionWindows.kt @@ -11,29 +11,33 @@ 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 com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel import com.kdroid.composetray.utils.SingleInstanceManager +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import com.kdroid.composetray.utils.composeNativeTrayloggingLevel import com.kdroid.composetray.utils.getTrayPosition import com.kdroid.composetray.utils.getTrayWindowPosition -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.kdroid.kmplog.i import composenativetray.demo.generated.resources.Res import composenativetray.demo.generated.resources.icon import org.jetbrains.compose.resources.painterResource fun main() = application { - Log.setDevelopmentMode(true) + allowComposeNativeTrayLogging = false + composeNativeTrayloggingLevel = ComposeNativeTrayLoggingLevel.DEBUG val logTag = "NativeTray" - - Log.d("TrayPosition", getTrayPosition().toString()) + + println("$logTag: TrayPosition: ${getTrayPosition()}") var isWindowVisible by remember { mutableStateOf(true) } var textVisible by remember { mutableStateOf(false) } - var alwaysShowTray by remember { mutableStateOf(false) } + var alwaysShowTray by remember { mutableStateOf(true) } var hideOnClose by remember { mutableStateOf(true) } + var notificationsEnabled by remember { mutableStateOf(false) } + var initialChecked by remember { mutableStateOf(true) } val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = { isWindowVisible = true @@ -62,7 +66,7 @@ fun main() = application { }, primaryAction = { isWindowVisible = true - Log.i(logTag, "On Primary Clicked") + println("$logTag: On Primary Clicked") }, primaryActionLabel = "Open the Application", tooltip = "My Application" @@ -71,46 +75,64 @@ fun main() = application { // Tools SubMenu SubMenu(label = "Tools") { Item(label = "Calculator") { - Log.i(logTag, "Calculator launched") + println("$logTag: Calculator launched") } Item(label = "Notepad") { - Log.i(logTag, "Notepad opened") + println("$logTag: Notepad opened") } } Divider() // Checkable Items - CheckableItem(label = "Enable notifications") { isChecked -> - Log.i(logTag, "Notifications ${if (isChecked) "enabled" else "disabled"}") - } - CheckableItem(label = "Initial Checked", checked = true) { isChecked -> - Log.i(logTag, "Initial Checked ${if (isChecked) "enabled" else "disabled"}") - } + CheckableItem( + label = "Enable notifications", + checked = notificationsEnabled, + onCheckedChange = { isChecked -> + notificationsEnabled = isChecked + println("$logTag: Notifications ${if (isChecked) "enabled" else "disabled"}") + } + ) + CheckableItem( + label = "Initial Checked", + checked = initialChecked, + onCheckedChange = { isChecked -> + initialChecked = isChecked + println("$logTag: Initial Checked ${if (isChecked) "enabled" else "disabled"}") + } + ) Divider() Item(label = "About") { - Log.i(logTag, "Application v1.0 - Developed by Elyahou") + println("$logTag: Application v1.0 - Developed by Elyahou") } Divider() - CheckableItem(label = "Always show tray", checked = alwaysShowTray) { isChecked -> - alwaysShowTray = isChecked - Log.i(logTag, "Always show tray ${if (isChecked) "enabled" else "disabled"}") - } - - CheckableItem(label = "Hide on close", checked = hideOnClose) { isChecked -> - hideOnClose = isChecked - Log.i(logTag, "Hide on close ${if (isChecked) "enabled" else "disabled"}") - } + CheckableItem( + label = "Always show tray", + checked = alwaysShowTray, + onCheckedChange = { isChecked -> + alwaysShowTray = isChecked + println("$logTag: Always show tray ${if (isChecked) "enabled" else "disabled"}") + } + ) + + CheckableItem( + label = "Hide on close", + checked = hideOnClose, + onCheckedChange = { isChecked -> + hideOnClose = isChecked + println("$logTag: Hide on close ${if (isChecked) "enabled" else "disabled"}") + } + ) Divider() Item(label = "Exit", isEnabled = true) { - Log.i(logTag, "Exiting the application") + println("$logTag: Exiting the application") dispose() exitApplication() } @@ -120,7 +142,7 @@ fun main() = application { } - val windowWidth = 800 + val windowWidth = 300 val windowHeight = 600 val windowPosition = getTrayWindowPosition(windowWidth, windowHeight) @@ -132,7 +154,7 @@ fun main() = application { exitApplication() } }, - state = rememberWindowState( + state = WindowState( width = windowWidth.dp, height = windowHeight.dp, position = windowPosition @@ -146,4 +168,4 @@ fun main() = application { hideOnClose = hideOnCloseState } } -} +} \ No newline at end of file diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithContextMenu.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithContextMenu.kt index 1064fb4..a69aa59 100644 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithContextMenu.kt +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithContextMenu.kt @@ -13,25 +13,38 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel import com.kdroid.composetray.utils.SingleInstanceManager +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import com.kdroid.composetray.utils.composeNativeTrayloggingLevel import com.kdroid.composetray.utils.getTrayPosition -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.kdroid.kmplog.i +import com.kdroid.composetray.utils.isMenuBarInDarkMode import composenativetray.demo.generated.resources.Res import composenativetray.demo.generated.resources.icon fun main() = application { - Log.setDevelopmentMode(true) + allowComposeNativeTrayLogging = true + composeNativeTrayloggingLevel = ComposeNativeTrayLoggingLevel.DEBUG + val logTag = "NativeTray" - Log.d("TrayPosition", getTrayPosition().toString()) + println("$logTag: TrayPosition: ${getTrayPosition()}") var isWindowVisible by remember { mutableStateOf(true) } var textVisible by remember { mutableStateOf(false) } var alwaysShowTray by remember { mutableStateOf(false) } var hideOnClose by remember { mutableStateOf(true) } + // Dynamic menu state + var showAdvancedOptions by remember { mutableStateOf(true) } + var dynamicItemLabel by remember { mutableStateOf("Dynamic Item") } + var itemCounter by remember { mutableStateOf(0) } + + // New idiomatic state management + var notificationsEnabled by remember { mutableStateOf(false) } + var darkModeEnabled by remember { mutableStateOf(false) } + var autoStartEnabled by remember { mutableStateOf(true) } + val isSingleInstance = SingleInstanceManager.isSingleInstance(onRestoreRequest = { isWindowVisible = true }) @@ -42,7 +55,6 @@ fun main() = application { } // Always create the Tray composable, but make it conditional on visibility - // This ensures it's recomposed when alwaysShowTray changes val showTray = alwaysShowTray || !isWindowVisible if (showTray) { @@ -51,34 +63,47 @@ fun main() = application { Icon( Icons.Default.Favorite, contentDescription = "", - // Use alwaysShowTray as a key to force recomposition when it changes - tint = Color.White, + tint = if (isMenuBarInDarkMode()) Color.White else Color.Black, modifier = Modifier.fillMaxSize() ) }, primaryAction = { isWindowVisible = true - Log.i(logTag, "On Primary Clicked") + println("$logTag: On Primary Clicked") }, primaryActionLabel = "Open the Application", tooltip = "My Application" + // Note: No menuKey needed anymore! ) { + // Dynamic item that changes label + Item(label = dynamicItemLabel) { + itemCounter++ + dynamicItemLabel = "Clicked $itemCounter times" + println("$logTag: Dynamic item clicked: $dynamicItemLabel") + } + + Divider() + // Options SubMenu SubMenu(label = "Options") { Item(label = "Show Text") { - Log.i(logTag, "Show Text selected") + println("$logTag: Show Text selected") textVisible = true } Item(label = "Hide Text") { - Log.i(logTag, "Hide Text selected") + println("$logTag: Hide Text selected") textVisible = false } - SubMenu(label = "Advanced Sub-options") { - Item(label = "Advanced Option 1") { - Log.i(logTag, "Advanced Option 1 selected") - } - Item(label = "Advanced Option 2") { - Log.i(logTag, "Advanced Option 2 selected") + + // Conditionally show advanced options + if (showAdvancedOptions) { + SubMenu(label = "Advanced Sub-options") { + Item(label = "Advanced Option 1") { + println("$logTag: Advanced Option 1 selected") + } + Item(label = "Advanced Option 2") { + println("$logTag: Advanced Option 2 selected") + } } } } @@ -88,40 +113,78 @@ fun main() = application { // Tools SubMenu SubMenu(label = "Tools") { Item(label = "Calculator") { - Log.i(logTag, "Calculator launched") + println("$logTag: Calculator launched") } Item(label = "Notepad") { - Log.i(logTag, "Notepad opened") + println("$logTag: Notepad opened") } } Divider() - // Checkable Items - CheckableItem(label = "Enable notifications") { isChecked -> - Log.i(logTag, "Notifications ${if (isChecked) "enabled" else "disabled"}") - } - CheckableItem(label = "Initial Checked", checked = true) { isChecked -> - Log.i(logTag, "Initial Checked ${if (isChecked) "enabled" else "disabled"}") - } + // New idiomatic CheckableItem usage + CheckableItem( + label = "Enable notifications", + checked = notificationsEnabled, + onCheckedChange = { checked -> + notificationsEnabled = checked + println("$logTag: Notifications ${if (checked) "enabled" else "disabled"}") + } + ) + + CheckableItem( + label = "Dark mode", + checked = darkModeEnabled, + onCheckedChange = { checked -> + darkModeEnabled = checked + println("$logTag: Dark mode ${if (checked) "enabled" else "disabled"}") + } + ) + + CheckableItem( + label = "Auto-start on login", + checked = autoStartEnabled, + onCheckedChange = { checked -> + autoStartEnabled = checked + println("$logTag: Auto-start ${if (checked) "enabled" else "disabled"}") + } + ) Divider() + // Toggle advanced options visibility + CheckableItem( + label = "Show advanced options", + checked = showAdvancedOptions, + onCheckedChange = { checked -> + showAdvancedOptions = checked + println("$logTag: Advanced options ${if (checked) "shown" else "hidden"}") + } + ) + Item(label = "About") { - Log.i(logTag, "Application v1.0 - Developed by Elyahou") + println("$logTag: Application v1.0 - Developed by Elyahou") } Divider() - CheckableItem(label = "Always show tray", checked = alwaysShowTray) { isChecked -> - alwaysShowTray = isChecked - Log.i(logTag, "Always show tray ${if (isChecked) "enabled" else "disabled"}") - } - - CheckableItem(label = "Hide on close", checked = hideOnClose) { isChecked -> - hideOnClose = isChecked - Log.i(logTag, "Hide on close ${if (isChecked) "enabled" else "disabled"}") - } + CheckableItem( + label = "Always show tray", + checked = alwaysShowTray, + onCheckedChange = { checked -> + alwaysShowTray = checked + println("$logTag: Always show tray ${if (checked) "enabled" else "disabled"}") + } + ) + + CheckableItem( + label = "Hide on close", + checked = hideOnClose, + onCheckedChange = { checked -> + hideOnClose = checked + println("$logTag: Hide on close ${if (checked) "enabled" else "disabled"}") + } + ) Divider() @@ -130,7 +193,7 @@ fun main() = application { } Item(label = "Exit", isEnabled = true) { - Log.i(logTag, "Exiting the application") + println("$logTag: Exiting the application") dispose() exitApplication() } @@ -147,13 +210,13 @@ fun main() = application { exitApplication() } }, - title = "Compose Desktop Application with Two Screens", + title = "Compose Desktop Application with Dynamic Tray Menu", visible = isWindowVisible, - icon = org.jetbrains.compose.resources.painterResource(Res.drawable.icon) // Optional: Set window icon + 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/com/kdroid/composetray/demo/DemoWithImageVector.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithImageVector.kt new file mode 100644 index 0000000..e1185f4 --- /dev/null +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithImageVector.kt @@ -0,0 +1,153 @@ +package com.kdroid.composetray.demo + +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 com.kdroid.composetray.demo.svg.AcademicCap +import com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel +import com.kdroid.composetray.utils.SingleInstanceManager +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import com.kdroid.composetray.utils.composeNativeTrayloggingLevel +import com.kdroid.composetray.utils.getTrayPosition +import composenativetray.demo.generated.resources.Res +import composenativetray.demo.generated.resources.icon +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 { + allowComposeNativeTrayLogging = false + composeNativeTrayloggingLevel = ComposeNativeTrayLoggingLevel.DEBUG + + val logTag = "ImageVectorTrayDemo" + + println("$logTag: TrayPosition: ${getTrayPosition()}") + + var isWindowVisible by remember { mutableStateOf(true) } + var alwaysShowTray by remember { mutableStateOf(true) } + var hideOnClose by remember { mutableStateOf(true) } + + // 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 + } + + // Always create the Tray composable, but make it conditional on visibility + val showTray = alwaysShowTray || !isWindowVisible + + if (showTray) { + // Using the ImageVector API with the AcademicCap vector + Tray( + icon = AcademicCap, // Using the ImageVector directly + tint = iconTint, // Using the tint parameter (null means auto-adapt to theme) + tooltip = "Academic Cap Demo", + primaryAction = { + isWindowVisible = true + println("$logTag: Primary action clicked") + }, + primaryActionLabel = "Open Application" + ) { + // Menu items to demonstrate changing the tint color + SubMenu(label = "Icon Color") { + Item(label = "Default (Auto)") { + iconTint = null + println("$logTag: Icon color set to default (auto)") + } + Item(label = "Red") { + iconTint = Color.Red + println("$logTag: Icon color set to red") + } + Item(label = "Green") { + iconTint = Color.Green + println("$logTag: Icon color set to green") + } + Item(label = "Blue") { + iconTint = Color.Blue + println("$logTag: Icon color set to blue") + } + Item(label = "Yellow") { + iconTint = Color.Yellow + println("$logTag: Icon color set to yellow") + } + } + + Divider() + + // Standard menu items + Item(label = "About") { + println("$logTag: ImageVector API Demo - Using AcademicCap vector") + } + + Divider() + + // Settings for tray visibility + CheckableItem( + label = "Always show tray", + checked = alwaysShowTray, + onCheckedChange = { checked -> + alwaysShowTray = checked + println("$logTag: Always show tray ${if (checked) "enabled" else "disabled"}") + } + ) + + CheckableItem( + label = "Hide on close", + checked = hideOnClose, + onCheckedChange = { checked -> + hideOnClose = checked + println("$logTag: Hide on close ${if (checked) "enabled" else "disabled"}") + } + ) + + Divider() + + Item(label = "Hide in tray") { + isWindowVisible = false + println("$logTag: Application hidden in tray") + } + + Item(label = "Exit") { + println("$logTag: Exiting application") + dispose() + exitApplication() + } + } + } + + Window( + onCloseRequest = { + if (hideOnClose) { + isWindowVisible = false + } else { + exitApplication() + } + }, + 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/com/kdroid/composetray/demo/DemoWithPainter.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithPainter.kt new file mode 100644 index 0000000..16959b4 --- /dev/null +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithPainter.kt @@ -0,0 +1,138 @@ +package com.kdroid.composetray.demo + +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 com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel +import com.kdroid.composetray.utils.SingleInstanceManager +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import com.kdroid.composetray.utils.composeNativeTrayloggingLevel +import com.kdroid.composetray.utils.getTrayPosition +import composenativetray.demo.generated.resources.Res +import composenativetray.demo.generated.resources.icon +import composenativetray.demo.generated.resources.icon2 +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 { + allowComposeNativeTrayLogging = true + composeNativeTrayloggingLevel = ComposeNativeTrayLoggingLevel.DEBUG + + val logTag = "PainterTrayDemo" + + println("$logTag: TrayPosition: ${getTrayPosition()}") + + var isWindowVisible by remember { mutableStateOf(true) } + var alwaysShowTray by remember { mutableStateOf(true) } + var hideOnClose by remember { mutableStateOf(true) } + + // 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 + } + + // Always create the Tray composable, but make it conditional on visibility + val showTray = alwaysShowTray || !isWindowVisible + + if (showTray) { + // Using the Painter API with the resource icons + Tray( + icon = painterResource(currentIcon), // Using the Painter directly + tooltip = "Painter Demo", + primaryAction = { + isWindowVisible = true + println("$logTag: Primary action clicked") + }, + primaryActionLabel = "Open Application" + ) { + // Menu item to switch between icons + Item(label = "Switch Icon") { + currentIcon = if (currentIcon == Res.drawable.icon) { + println("$logTag: Switched to icon2") + Res.drawable.icon2 + } else { + println("$logTag: Switched to icon") + Res.drawable.icon + } + } + + Divider() + + // Standard menu items + Item(label = "About") { + println("$logTag: Painter API Demo - Using resource icons") + } + + Divider() + + // Settings for tray visibility + CheckableItem( + label = "Always show tray", + checked = alwaysShowTray, + onCheckedChange = { checked -> + alwaysShowTray = checked + println("$logTag: Always show tray ${if (checked) "enabled" else "disabled"}") + } + ) + + CheckableItem( + label = "Hide on close", + checked = hideOnClose, + onCheckedChange = { checked -> + hideOnClose = checked + println("$logTag: Hide on close ${if (checked) "enabled" else "disabled"}") + } + ) + + Divider() + + Item(label = "Hide in tray") { + isWindowVisible = false + println("$logTag: Application hidden in tray") + } + + Item(label = "Exit") { + println("$logTag: Exiting application") + dispose() + exitApplication() + } + } + } + + Window( + onCloseRequest = { + if (hideOnClose) { + isWindowVisible = false + } else { + exitApplication() + } + }, + 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/com/kdroid/composetray/demo/DemoWithoutContextMenu.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithoutContextMenu.kt index 6f3cc4c..ea4392d 100644 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithoutContextMenu.kt +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DemoWithoutContextMenu.kt @@ -14,19 +14,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel import com.kdroid.composetray.utils.SingleInstanceManager +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import com.kdroid.composetray.utils.composeNativeTrayloggingLevel import com.kdroid.composetray.utils.getTrayPosition -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.kdroid.kmplog.i import composenativetray.demo.generated.resources.Res import composenativetray.demo.generated.resources.icon fun main() = application { - Log.setDevelopmentMode(true) - val logTag = "NativeTray" + allowComposeNativeTrayLogging = true + composeNativeTrayloggingLevel = ComposeNativeTrayLoggingLevel.DEBUG - Log.d("TrayPosition", getTrayPosition().toString()) + val logTag = "NativeTray" + + println("$logTag: TrayPosition: ${getTrayPosition()}") var isWindowVisible by remember { mutableStateOf(true) } var textVisible by remember { mutableStateOf(false) } @@ -55,7 +57,7 @@ fun main() = application { }, primaryAction = { isWindowVisible = true - Log.i(logTag, "On Primary Clicked") + println("$logTag: On Primary Clicked") }, primaryActionLabel = "Open the Application", tooltip = "My Application" @@ -79,4 +81,4 @@ fun main() = application { hideOnClose = hideOnCloseState } } -} +} \ No newline at end of file diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicTrayMenu.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicTrayMenu.kt index c463ba5..1353639 100644 --- a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicTrayMenu.kt +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicTrayMenu.kt @@ -8,25 +8,30 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.kdroid.composetray.tray.api.Tray +import com.kdroid.composetray.utils.ComposeNativeTrayLoggingLevel import com.kdroid.composetray.utils.SingleInstanceManager +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import com.kdroid.composetray.utils.composeNativeTrayloggingLevel import com.kdroid.composetray.utils.getTrayPosition -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.kdroid.kmplog.i 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() = application { - Log.setDevelopmentMode(true) val logTag = "NativeTray" + allowComposeNativeTrayLogging = true + composeNativeTrayloggingLevel = ComposeNativeTrayLoggingLevel.DEBUG - Log.d("TrayPosition", getTrayPosition().toString()) + println("$logTag: TrayPosition: ${getTrayPosition()}") var isWindowVisible by remember { mutableStateOf(true) } var textVisible by remember { mutableStateOf(false) } @@ -44,11 +49,13 @@ fun main() = application { } val running = serviceStatus == ServiceStatus.RUNNING - var icon by remember { mutableStateOf(Res.drawable.icon) } + var icon by remember { mutableStateOf(Res.drawable.icon) } // Always create the Tray composable, but make it conditional on visibility // This ensures it's recomposed when alwaysShowTray changes val showTray = alwaysShowTray || !isWindowVisible + var isVisible by remember { mutableStateOf(true) } + var name by remember { mutableStateOf("Change Item Name") } if (showTray) { Tray( @@ -61,22 +68,31 @@ fun main() = application { }, primaryAction = { isWindowVisible = true - Log.i(logTag, "On Primary Clicked") + println("$logTag: On Primary Clicked") }, primaryActionLabel = "Open the Application", tooltip = "My Application", + // Pass isVisible and name as menuKey to force recomposition when they change menuContent = { Item("Change icon") { icon = if (icon == Res.drawable.icon) Res.drawable.icon2 else Res.drawable.icon } + if (isVisible) { + Item("Hide Me") { + isVisible = false + } + } + Item(name) { + name = "I've changed !" + } // Dynamic Service Menu SubMenu(label = "Service Control") { Item(label = "Start Service", isEnabled = !running) { - Log.i(logTag, "Start Service selected") + println("$logTag: Start Service selected") serviceStatus = ServiceStatus.RUNNING } Item(label = "Stop Service", isEnabled = running) { - Log.i(logTag, "Stop Service selected") + println("$logTag: Stop Service selected") serviceStatus = ServiceStatus.STOPPED } Item(label = "Service Status: ${if (running) "Running" else "Stopped"}", isEnabled = false) @@ -87,11 +103,11 @@ fun main() = application { // Options SubMenu SubMenu(label = "Options") { Item(label = "Show Text") { - Log.i(logTag, "Show Text selected") + println("$logTag: Show Text selected") textVisible = true } Item(label = "Hide Text") { - Log.i(logTag, "Hide Text selected") + println("$logTag: Hide Text selected") textVisible = false } } @@ -99,25 +115,33 @@ fun main() = application { Divider() Item(label = "About") { - Log.i(logTag, "Application v1.0 - Developed by Elyahou") + println("$logTag: Application v1.0 - Developed by Elyahou") } Divider() - CheckableItem(label = "Always show tray", checked = alwaysShowTray) { isChecked -> - alwaysShowTray = isChecked - Log.i(logTag, "Always show tray ${if (isChecked) "enabled" else "disabled"}") - } + CheckableItem( + label = "Always show tray", + checked = alwaysShowTray, + onCheckedChange = { isChecked -> + alwaysShowTray = isChecked + println("$logTag: Always show tray ${if (isChecked) "enabled" else "disabled"}") + } + ) - CheckableItem(label = "Hide on close", checked = hideOnClose) { isChecked -> - hideOnClose = isChecked - Log.i(logTag, "Hide on close ${if (isChecked) "enabled" else "disabled"}") - } + CheckableItem( + label = "Hide on close", + checked = hideOnClose, + onCheckedChange = { isChecked -> + hideOnClose = isChecked + println("$logTag: Hide on close ${if (isChecked) "enabled" else "disabled"}") + } + ) Divider() Item(label = "Exit", isEnabled = true) { - Log.i(logTag, "Exiting the application") + println("$logTag: Exiting the application") dispose() exitApplication() } @@ -137,9 +161,10 @@ fun main() = application { 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/com/kdroid/composetray/demo/svg/AcademicCap.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/svg/AcademicCap.kt new file mode 100644 index 0000000..8b59300 --- /dev/null +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/svg/AcademicCap.kt @@ -0,0 +1,60 @@ +package com.kdroid.composetray.demo.svg + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val AcademicCap: ImageVector + get() { + if (_AcademicCap != null) return _AcademicCap!! + + _AcademicCap = ImageVector.Builder( + name = "AcademicCap", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + path( + stroke = SolidColor(Color(0xFF0F172A)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(4.25933f, 10.1466f) + curveTo(3.98688f, 12.2307f, 3.82139f, 14.3483f, 3.76853f, 16.494f) + curveTo(6.66451f, 17.703f, 9.41893f, 19.1835f, 12f, 20.9036f) + curveTo(14.5811f, 19.1835f, 17.3355f, 17.703f, 20.2315f, 16.494f) + curveTo(20.1786f, 14.3484f, 20.0131f, 12.2307f, 19.7407f, 10.1467f) + moveTo(4.25933f, 10.1466f) + curveTo(3.38362f, 9.8523f, 2.49729f, 9.58107f, 1.60107f, 9.3337f) + curveTo(4.84646f, 7.05887f, 8.32741f, 5.0972f, 12f, 3.49255f) + curveTo(15.6727f, 5.0972f, 19.1536f, 7.05888f, 22.399f, 9.33371f) + curveTo(21.5028f, 9.58109f, 20.6164f, 9.85233f, 19.7407f, 10.1467f) + moveTo(4.25933f, 10.1466f) + curveTo(6.94656f, 11.0499f, 9.5338f, 12.1709f, 12.0001f, 13.4886f) + curveTo(14.4663f, 12.1709f, 17.0535f, 11.0499f, 19.7407f, 10.1467f) + moveTo(6.75f, 15f) + curveTo(7.16421f, 15f, 7.5f, 14.6642f, 7.5f, 14.25f) + curveTo(7.5f, 13.8358f, 7.16421f, 13.5f, 6.75f, 13.5f) + curveTo(6.33579f, 13.5f, 6f, 13.8358f, 6f, 14.25f) + curveTo(6f, 14.6642f, 6.33579f, 15f, 6.75f, 15f) + close() + moveTo(6.75f, 15f) + verticalLineTo(11.3245f) + curveTo(8.44147f, 10.2735f, 10.1936f, 9.31094f, 12f, 8.44329f) + moveTo(4.99264f, 19.9926f) + curveTo(6.16421f, 18.8211f, 6.75f, 17.2855f, 6.75f, 15.75f) + verticalLineTo(14.25f) + } + }.build() + + return _AcademicCap!! + } + +private var _AcademicCap: ImageVector? = null + diff --git a/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/svg/Deployed_code_update.kt b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/svg/Deployed_code_update.kt new file mode 100644 index 0000000..bc0d338 --- /dev/null +++ b/demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/svg/Deployed_code_update.kt @@ -0,0 +1,78 @@ +package com.kdroid.composetray.demo.svg + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Deployed_code_update: ImageVector + get() { + if (_Deployed_code_update != null) return _Deployed_code_update!! + + _Deployed_code_update = ImageVector.Builder( + name = "Deployed_code_update", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f + ).apply { + path( + fill = SolidColor(Color(0xFF000000)) + ) { + moveToRelative(720f, -80f) + lineToRelative(120f, -120f) + lineToRelative(-28f, -28f) + lineToRelative(-72f, 72f) + verticalLineToRelative(-164f) + horizontalLineToRelative(-40f) + verticalLineToRelative(164f) + lineToRelative(-72f, -72f) + lineToRelative(-28f, 28f) + close() + moveTo(480f, 160f) + lineTo(243f, 297f) + lineToRelative(237f, 137f) + lineToRelative(237f, -137f) + close() + moveTo(120f, 639f) + verticalLineToRelative(-318f) + quadToRelative(0f, -22f, 10.5f, -40f) + reflectiveQuadToRelative(29.5f, -29f) + lineToRelative(280f, -161f) + quadToRelative(10f, -5f, 19.5f, -8f) + reflectiveQuadToRelative(20.5f, -3f) + reflectiveQuadToRelative(21f, 3f) + reflectiveQuadToRelative(19f, 8f) + lineToRelative(280f, 161f) + quadToRelative(19f, 11f, 29.5f, 29f) + reflectiveQuadToRelative(10.5f, 40f) + verticalLineToRelative(159f) + horizontalLineToRelative(-80f) + verticalLineToRelative(-116f) + lineTo(479f, 526f) + lineTo(200f, 364f) + verticalLineToRelative(274f) + lineToRelative(240f, 139f) + verticalLineToRelative(92f) + lineTo(160f, 708f) + quadToRelative(-19f, -11f, -29.5f, -29f) + reflectiveQuadTo(120f, 639f) + moveTo(720f, 960f) + quadToRelative(-83f, 0f, -141.5f, -58.5f) + reflectiveQuadTo(520f, 760f) + reflectiveQuadToRelative(58.5f, -141.5f) + reflectiveQuadTo(720f, 560f) + reflectiveQuadToRelative(141.5f, 58.5f) + reflectiveQuadTo(920f, 760f) + reflectiveQuadTo(861.5f, 901.5f) + reflectiveQuadTo(720f, 960f) + moveTo(480f, 469f) + } + }.build() + + return _Deployed_code_update!! + } + +private var _Deployed_code_update: ImageVector? = null + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f66fb7..4b3f431 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,14 +4,17 @@ kotlin = "2.2.0" kotlinx-coroutines = "1.10.2" compose = "1.8.0" jna = "5.17.0" -platformtools = "0.4.0" +platformtools = "0.5.0" [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" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } platformtools-core = { module = "io.github.kdroidfilter:platformtools.core", version.ref = "platformtools" } +platformtools-darkmodedetector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version.ref = "platformtools" } + kmp-log = { module = "io.github.kdroidfilter:kmplog", version = "0.3.0" } [plugins] diff --git a/maclib/build.sh b/maclib/build.sh new file mode 100755 index 0000000..b957800 --- /dev/null +++ b/maclib/build.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# Exit on error +set -e + +echo "Building MacTray library..." + +echo "Building for ARM64 (Apple Silicon)..." + +swiftc -emit-library -o ../src/commonMain/resources/darwin-aarch64/libMacTray.dylib \ + -module-name MacTray \ + -swift-version 5 \ + -O -whole-module-optimization \ + -framework Foundation \ + -framework Cocoa \ + -Xlinker -rpath -Xlinker @executable_path/../Frameworks \ + -Xlinker -rpath -Xlinker @loader_path/Frameworks \ + tray.swift + +echo "Building for x86_64 (Intel)..." + +swiftc -emit-library -o ../src/commonMain/resources/darwin-x86-64/libMacTray.dylib \ + -module-name MacTray \ + -swift-version 5 \ + -target x86_64-apple-macosx10.14 \ + -O -whole-module-optimization \ + -framework Foundation \ + -framework Cocoa \ + -Xlinker -rpath -Xlinker @executable_path/../Frameworks \ + -Xlinker -rpath -Xlinker @loader_path/Frameworks \ + tray.swift + +echo "Build completed successfully." diff --git a/maclib/tray.h b/maclib/tray.h new file mode 100644 index 0000000..db3bb00 --- /dev/null +++ b/maclib/tray.h @@ -0,0 +1,79 @@ +/* tray.h - Public API, C99 / C++98 compatible */ +#ifndef TRAY_H +#define TRAY_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* -------------------------------------------------------------------------- */ +/* Symbol export */ +/* -------------------------------------------------------------------------- */ +#if defined(_WIN32) && !defined(TRAY_STATIC) +# ifdef TRAY_EXPORTS +# define TRAY_API __declspec(dllexport) +# else +# define TRAY_API __declspec(dllimport) +# endif +#elif defined(__GNUC__) || defined(__clang__) +# define TRAY_API __attribute__((visibility("default"))) +#else +# define TRAY_API +#endif + +/* -------------------------------------------------------------------------- */ +/* Callback types */ +/* -------------------------------------------------------------------------- */ +struct tray; +struct tray_menu_item; + +typedef void (*tray_menu_item_callback)(struct tray_menu_item *item); +typedef void (*tray_callback) (struct tray *tray); +typedef void (*theme_callback) (int is_dark /* 1 = dark, 0 = light */); + +/* -------------------------------------------------------------------------- */ +/* Structures */ +/* -------------------------------------------------------------------------- */ +struct tray_menu_item { + const char *text; /* label or "-" for separator */ + int disabled; /* 1 = grayed out */ + int checked; /* 1 = checked */ + tray_menu_item_callback cb; /* callback (NULL if none) */ + struct tray_menu_item *submenu; /* submenu or NULL */ +}; + +struct tray { + const char *icon_filepath; /* path to icon file */ + const char *tooltip; /* tooltip */ + struct tray_menu_item *menu; /* root menu (NULL = none) */ + tray_callback cb; /* left click (NULL = menu) */ +}; + +/* -------------------------------------------------------------------------- */ +/* Lifecycle */ +/* -------------------------------------------------------------------------- */ +TRAY_API struct tray *tray_get_instance(void); + +TRAY_API int tray_init (struct tray *tray); /* 0 = OK, <0 = error */ +TRAY_API int tray_loop (int blocking); /* 0 = running, -1 = finished */ +TRAY_API void tray_update(struct tray *tray); /* refresh icon / menu */ +TRAY_API void tray_exit (void); /* free everything and exit */ + +/* -------------------------------------------------------------------------- */ +/* Additional options / information */ +/* -------------------------------------------------------------------------- */ +TRAY_API void tray_set_theme_callback(theme_callback cb); +TRAY_API int tray_is_menu_dark(void); /* 1 = dark mode */ + +/* Windows: corner and coordinates of notification area */ +TRAY_API int tray_get_notification_icons_position(int *x, int *y); +TRAY_API const char *tray_get_notification_icons_region(void); + +/* macOS: corner and coordinates of status item */ +TRAY_API int tray_get_status_item_position(int *x, int *y); +TRAY_API const char *tray_get_status_item_region(void); + +#ifdef __cplusplus +} /* extern "C" */ +#endif +#endif /* TRAY_H */ \ No newline at end of file diff --git a/maclib/tray.swift b/maclib/tray.swift new file mode 100644 index 0000000..e5e7186 --- /dev/null +++ b/maclib/tray.swift @@ -0,0 +1,306 @@ +import Cocoa +import Foundation + +// Types for C callbacks +public typealias TrayCallback = @convention(c) (UnsafeMutableRawPointer?) -> Void +public typealias MenuItemCallback = @convention(c) (UnsafeMutableRawPointer?) -> Void +public typealias ThemeCallback = @convention(c) (Int32) -> Void + +// MARK: - Static globals (kept for C interop) +private var loopStatus: Int32 = 0 +private var trayInstance: UnsafeMutableRawPointer? = nil +private var app: NSApplication? = nil +private var statusBar: NSStatusBar? = nil +private var statusItem: NSStatusItem? = nil +private var themeCallback: ThemeCallback? = nil + +// MARK: - Menu delegate +private class MenuDelegate: NSObject, NSMenuDelegate { + @objc func menuItemClicked(_ sender: NSMenuItem) { + guard let menuItemPtr = sender.representedObject as? UnsafeMutableRawPointer else { return } + let callbackPtr = menuItemPtr.advanced(by: 16) + .assumingMemoryBound(to: MenuItemCallback?.self) + callbackPtr.pointee?(menuItemPtr) + } + + func menuWillOpen(_ menu: NSMenu) { + guard menu == statusItem?.menu, + let currentEvent = NSApp.currentEvent, + currentEvent.buttonNumber == 0, + let trayPtr = trayInstance else { return } + + // Left‑click: cancel menu & fire callback immediately + menu.cancelTracking() + let callbackPtr = trayPtr.advanced(by: 24) + .assumingMemoryBound(to: TrayCallback?.self) + callbackPtr.pointee?(trayPtr) + } +} + +// MARK: - Left‑click handler when no menu is present +@objc private class ButtonClickHandler: NSObject { + @objc func handleClick(_ sender: NSStatusBarButton) { + guard let trayPtr = trayInstance else { return } + let callbackPtr = trayPtr.advanced(by: 24) + .assumingMemoryBound(to: TrayCallback?.self) + callbackPtr.pointee?(trayPtr) + } +} + +// MARK: - Appearance observer with ultra‑low latency +/// Detects menu‑bar theme changes in <60 ms using KVO + GCD debouncing. +private class MenuBarAppearanceObserver { + private var observation: NSKeyValueObservation? + private var workItem: DispatchWorkItem? + private var lastAppearance: NSAppearance.Name? + + /// Debounce delay before first evaluation (keep tiny but non‑zero). + private let debounce: TimeInterval = 0.04 // 40 ms + /// Settling delay to avoid reporting intermediate states. + private let settle: TimeInterval = 0.005 // 5 ms + + func startObserving(_ statusItem: NSStatusItem) { + observation = statusItem.button?.observe( + \.effectiveAppearance, + options: [.initial, .new] + ) { [weak self] button, _ in + self?.scheduleCheck(for: button.effectiveAppearance) + } + } + + private func scheduleCheck(for appearance: NSAppearance) { + workItem?.cancel() + + let item = DispatchWorkItem { [weak self] in + self?.evaluate(appearance) + } + workItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + debounce, execute: item) + } + + private func evaluate(_ appearance: NSAppearance) { + guard let matched = appearance.bestMatch(from: [.darkAqua, .aqua]), + matched != lastAppearance else { return } + lastAppearance = matched + + // Allow the system a single run‑loop to settle, then notify. + DispatchQueue.main.asyncAfter(deadline: .now() + settle) { + themeCallback?(matched == .darkAqua ? 1 : 0) + } + } + + func invalidate() { + observation?.invalidate() + observation = nil + workItem?.cancel() + } +} + +// MARK: - Globals that need to live for app lifetime +private var menuDelegate: MenuDelegate? +private var buttonClickHandler: ButtonClickHandler? +private var appearanceObserver: MenuBarAppearanceObserver? + +// MARK: - Helpers +private func nativeMenu(from menuPtr: UnsafeMutableRawPointer) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.delegate = menuDelegate + + var currentPtr = menuPtr + while true { + guard let textPtr = currentPtr.load(as: UnsafePointer?.self) else { break } + let title = String(cString: textPtr) + + if title == "-" { + menu.addItem(NSMenuItem.separator()) + } else { + let disabled = currentPtr.advanced(by: 8).load(as: Int32.self) == 1 + let checked = currentPtr.advanced(by: 12).load(as: Int32.self) == 1 + let callback = currentPtr.advanced(by: 16).load(as: MenuItemCallback?.self) + let submenu = currentPtr.advanced(by: 24).load(as: UnsafeMutableRawPointer?.self) + + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = !disabled + item.state = checked ? .on : .off + item.representedObject = currentPtr + + if callback != nil { + item.target = menuDelegate + item.action = #selector(MenuDelegate.menuItemClicked(_:)) + } + menu.addItem(item) + + if let submenuPtr = submenu { + menu.setSubmenu(nativeMenu(from: submenuPtr), for: item) + } + } + + currentPtr = currentPtr.advanced(by: 32) + } + return menu +} + +// MARK: - C shim +@_cdecl("tray_get_instance") +public func tray_get_instance() -> UnsafeMutableRawPointer? { trayInstance } + +@_cdecl("tray_init") +public func tray_init(_ tray: UnsafeMutableRawPointer) -> Int32 { + // Guarantee work is on main thread. + if !Thread.isMainThread { + return DispatchQueue.main.sync { tray_init(tray) } + } + + loopStatus = 0 + menuDelegate = MenuDelegate() + buttonClickHandler = ButtonClickHandler() + appearanceObserver = MenuBarAppearanceObserver() + + app = NSApplication.shared + statusBar = NSStatusBar.system + statusItem = statusBar?.statusItem(withLength: NSStatusItem.variableLength) + + if let statusItem = statusItem { + appearanceObserver?.startObserving(statusItem) + } + + tray_update(tray) + return 0 +} + +@_cdecl("tray_loop") +public func tray_loop(_ blocking: Int32) -> Int32 { + if !Thread.isMainThread { + return DispatchQueue.main.sync { tray_loop(blocking) } + } + + let until = blocking != 0 ? Date.distantFuture : Date.distantPast + if let event = app?.nextEvent(matching: .any, until: until, inMode: .default, dequeue: true) { + app?.sendEvent(event) + } + return loopStatus +} + +@_cdecl("tray_update") +public func tray_update(_ tray: UnsafeMutableRawPointer) { + if !Thread.isMainThread { + return DispatchQueue.main.async { tray_update(tray) } + } + + trayInstance = tray + + let iconPathPtr = tray.load(as: UnsafePointer?.self) + let tooltipPtr = tray.advanced(by: 8).load(as: UnsafePointer?.self) + let menuPtr = tray.advanced(by: 16).load(as: UnsafeMutableRawPointer?.self) + let callbackPtr = tray.advanced(by: 24).load(as: TrayCallback?.self) + + if let iconPath = iconPathPtr.flatMap({ String(cString: $0) }), + let image = NSImage(contentsOfFile: iconPath) { + let height = NSStatusBar.system.thickness + let width = image.size.width * (height / image.size.height) + image.size = NSSize(width: width, height: height) + statusItem?.button?.image = image + } + + statusItem?.button?.toolTip = tooltipPtr.flatMap { String(cString: $0) } + + if let menuPtr = menuPtr { + statusItem?.menu = nativeMenu(from: menuPtr) + statusItem?.button?.target = nil + statusItem?.button?.action = nil + } else if callbackPtr != nil { + statusItem?.menu = nil + statusItem?.button?.target = buttonClickHandler + statusItem?.button?.action = #selector(ButtonClickHandler.handleClick(_:)) + statusItem?.button?.sendAction(on: [.leftMouseUp]) + } else { + statusItem?.menu = nil + statusItem?.button?.target = nil + statusItem?.button?.action = nil + } +} + +@_cdecl("tray_exit") +public func tray_exit() { + if !Thread.isMainThread { + return DispatchQueue.main.async { tray_exit() } + } + + loopStatus = -1 + appearanceObserver?.invalidate() + + if let statusItem = statusItem { + NSStatusBar.system.removeStatusItem(statusItem) + } + + trayInstance = nil + statusItem = nil + menuDelegate = nil + buttonClickHandler = nil + appearanceObserver = nil +} + +@_cdecl("tray_set_theme_callback") +public func tray_set_theme_callback(_ cb: @escaping ThemeCallback) { + themeCallback = cb +} + +@_cdecl("tray_is_menu_dark") +public func tray_is_menu_dark() -> Int32 { + guard let name = statusItem?.button?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) else { + return 1 // assume dark if unknown + } + return name == .darkAqua ? 1 : 0 +} + +// MARK: - Status‑item geometry (exported to C) + +// Returns 1 if the coordinate is precise, 0 if we had to use a fallback. +@_cdecl("tray_get_status_item_position") +public func tray_get_status_item_position( + _ x: UnsafeMutablePointer?, + _ y: UnsafeMutablePointer? +) -> Int32 +{ + guard + let button = statusItem?.button, + let window = button.window, + let screen = window.screen + else { + x?.pointee = 0 + y?.pointee = 0 + return 0 // unreliable coordinates + } + + // Button frame in screen space (origin at bottom-left) + var rect = button.convert(button.bounds, to: nil) + rect = window.convertToScreen(rect) + + // -- X --------------------------------------------------------------- + // Horizontal center of the icon (ideal for centered placement) + x?.pointee = Int32(lround(rect.midX)) + + // -- Y --------------------------------------------------------------- + // Inverted coordinate system to match Windows/Linux (origin at top) + let flippedY = Int32(screen.frame.maxY - rect.maxY) + y?.pointee = flippedY + + return 1 // precise coordinates +} + +/// Returns "top-left" or "top-right" (menu-bar always at top). +@_cdecl("tray_get_status_item_region") +public func tray_get_status_item_region() -> UnsafeMutablePointer? { + guard let button = statusItem?.button, + let screen = button.window?.screen else { + return strdup("top-right") // default value + } + + let rect = button.window!.convertToScreen( + button.convert(button.bounds, to: nil) + ) + let midX = screen.frame.midX + let region = rect.minX < midX ? "top-left" : "top-right" + return strdup(region) // to be freed on JVM/JNA side +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt new file mode 100644 index 0000000..d6a3dce --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt @@ -0,0 +1,414 @@ +package com.kdroid.composetray.lib.linux + +import com.kdroid.composetray.utils.* +import com.sun.jna.Pointer +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * Manages an SNI‑based tray icon on Linux. + * + *

Changelog 2025‑07‑26

+ * • Deduplication of menu refresh via `refreshPending`.
+ * • New: Incremental update of item checkable state without + * rebuilding the entire menu: `updateMenuItemCheckedState()` uses the fast + * native path when the GTK/Qt pointer of the item is known, and only falls back + * to a complete `recreateMenu()` when necessary.
+ * • New: Incremental icon update without rebuilding + * the entire menu: `updateIconPath()` uses the fast native path and only falls back + * in case of error.
+ * • New: Capture of click position for precise window placement. + */ +internal class LinuxTrayManager( + private var iconPath: String, + private var tooltip: String = "", + private var onLeftClick: (() -> Unit)? = null, + private var primaryActionLabel: String +) { + private val sni = SNIWrapper.INSTANCE + + // Native handles --------------------------------------------------------------------------- + private var trayHandle: Pointer? = null + private var menuHandle: Pointer? = null + + // State ------------------------------------------------------------------------------------ + private val menuItems: MutableList = mutableListOf() + private val running = AtomicBoolean(false) + private val lock = ReentrantLock() + + // Threading ------------------------------------------------------------------------------- + private var trayThread: Thread? = null + private val initLatch = CountDownLatch(1) + private var shutdownHook: Thread? = null + private val taskQueue: ConcurrentLinkedQueue<() -> Unit> = ConcurrentLinkedQueue() + + // GC safety ------------------------------------------------------------------------------- + private val callbackReferences: MutableList = mutableListOf() + private val menuItemReferences: MutableMap = mutableMapOf() + + private var activateCallback: SNIWrapper.ActivateCallback? = null + private var secondaryActivateCallback: SNIWrapper.SecondaryActivateCallback? = null + private var scrollCallback: SNIWrapper.ScrollCallback? = null + + // ----------------------------------------------------------------------- menu refresh gate + private val refreshPending = AtomicBoolean(false) + private val lastRefreshRequest = AtomicLong(0) + private val REFRESH_WINDOW_MS = 120L // fusionne les refresh rapprochés + + private fun requestMenuRefresh() { + lastRefreshRequest.set(System.currentTimeMillis()) + if (refreshPending.compareAndSet(false, true)) { + runOnTrayThread { + // petite fenêtre de debounce + while (System.currentTimeMillis() - lastRefreshRequest.get() < REFRESH_WINDOW_MS) { + Thread.sleep(REFRESH_WINDOW_MS) + } + try { + recreateMenu() + } finally { + refreshPending.set(false) + } + } + } + } + + // --------------------------------------------------------------------------- util helpers + private fun runOnTrayThread(action: () -> Unit) { + taskQueue += action + } + + private fun schedule(action: () -> Unit) { // retained for future use by callers + taskQueue += action + } + + // ----------------------------------------------------------------------------- menu model + data class MenuItem( + val text: String, + val isEnabled: Boolean = true, + val isCheckable: Boolean = false, + val isChecked: Boolean = false, + val onClick: (() -> Unit)? = null, + val subMenuItems: List = emptyList() + ) + + fun addMenuItem(menuItem: MenuItem) { + lock.withLock { menuItems.add(menuItem) } + } + + /** + * Updates the checked / unchecked state of an item in real-time without + * triggering a complete menu rebuild, whenever possible. + */ + fun updateMenuItemCheckedState(label: String, isChecked: Boolean) { + var fallbackRefreshNeeded = false + lock.withLock { + // 1. Mettre à jour le modèle mémoire --------------------------------------------- + val idx = menuItems.indexOfFirst { it.text == label } + if (idx != -1) { + menuItems[idx] = menuItems[idx].copy(isChecked = isChecked) + } + + // 2. Chemin rapide : on possède déjà le pointeur natif de l'item ⇒ mise à jour GTK/Qt + val ptr = menuItemReferences[label] + if (ptr != null && trayHandle != null) { + try { + // Méthode à exposer dans SNIWrapper : + // int set_menu_item_checked(Pointer menuItem, int checked) + val ok = sni.set_menu_item_checked(ptr, if (isChecked) 1 else 0) + if (ok == 0) { + // SNI l'a fait, on force le repaint global (léger) + sni.tray_update(trayHandle) + } else { + // Implé pas dispo ou erreur : on repassera par recreateMenu + fallbackRefreshNeeded = true + } + } catch (_: UnsatisfiedLinkError) { + // Version de lib sans cette fonction : on repassera par recreateMenu + fallbackRefreshNeeded = true + } + } else { + // Pointeur pas encore connu (menu pas construit) : on refera un refresh complet + fallbackRefreshNeeded = true + } + } + if (fallbackRefreshNeeded) requestMenuRefresh() + } + + // ---------------------------------------------------------------------------------- update + fun update( + newIconPath: String, + newTooltip: String, + newOnLeftClick: (() -> Unit)?, + newPrimaryActionLabel: String, + newMenuItems: List? = null + ) { + var iconChanged: Boolean + var tooltipChanged: Boolean + var needsMenuRefresh = false + + lock.withLock { + if (!running.get() || trayHandle == null) return + + iconChanged = iconPath != newIconPath + tooltipChanged = tooltip != newTooltip + + iconPath = newIconPath + tooltip = newTooltip + onLeftClick = newOnLeftClick + primaryActionLabel = newPrimaryActionLabel + + if (newMenuItems != null) { + menuItems.clear() + menuItems.addAll(newMenuItems) + needsMenuRefresh = true + } + } + + if (iconChanged) sni.update_icon_by_path(trayHandle, newIconPath) + if (tooltipChanged) sni.set_tooltip_title(trayHandle, newTooltip) + + if (needsMenuRefresh) requestMenuRefresh() + } + + // --------------------------------------------------------------------------- menu creation + private fun recreateMenu() { + infoln { "LinuxTrayManager: Recreating menu" } + if (!running.get() || trayHandle == null) { + warnln { "LinuxTrayManager: Cannot recreate menu, tray is not running or trayHandle is null" } + return + } + + try { + callbackReferences.clear() + menuItemReferences.clear() + + if (menuItems.isNotEmpty()) { + // Create or clear existing menu ------------------------------------------------ + if (menuHandle == null) { + menuHandle = sni.create_menu() + if (menuHandle == null) { + errorln { "LinuxTrayManager: Failed to create menu" } + return + } + infoln { "LinuxTrayManager: Menu created" } + } else { + try { + sni.clear_menu(menuHandle) + infoln { "LinuxTrayManager: Menu cleared" } + } catch (_: Exception) { + // Fallback: recreate menu completely when clear_menu is unavailable + val oldMenu = menuHandle + menuHandle = sni.create_menu() + if (menuHandle != null) { + sni.set_context_menu(trayHandle, null) + Thread.sleep(50) + sni.destroy_menu(oldMenu!!) + } else { + menuHandle = oldMenu + return + } + } + } + + // Add menu items -------------------------------------------------------------- + menuItems.forEach { addNativeMenuItem(menuHandle!!, it) } + sni.set_context_menu(trayHandle, menuHandle) + infoln { "LinuxTrayManager: Context menu set" } + } else { + // No items: remove menu -------------------------------------------------------- + if (menuHandle != null) { + sni.set_context_menu(trayHandle, null) + Thread.sleep(50) + sni.destroy_menu(menuHandle!!) + menuHandle = null + infoln { "LinuxTrayManager: Menu removed" } + } + } + + // Force visual update if the API provides it --------------------------------------- + try { + sni.tray_update(trayHandle) + infoln { "LinuxTrayManager: Tray update forced" } + } catch (e: Exception) { + warnln { "LinuxTrayManager: tray_update not available: ${e.message}" } + } + } catch (e: Exception) { + errorln { "LinuxTrayManager: Error recreating menu: ${e.message}" } + e.printStackTrace() + } + } + + // ------------------------------------------------------------------------------- tray life + fun startTray() { + lock.withLock { + if (running.get()) return + running.set(true) + } + + // Register shutdown hook -------------------------------------------------------------- + shutdownHook = Thread { stopTray() }.also { Runtime.getRuntime().addShutdownHook(it) } + + // Main event loop --------------------------------------------------------------------- + trayThread = Thread { + try { + val initResult = sni.init_tray_system() + if (initResult != 0) throw IllegalStateException("Failed to initialize tray system: $initResult") + + trayHandle = sni.create_tray("composetray-${System.currentTimeMillis()}") + if (trayHandle == null) { + sni.shutdown_tray_system() + throw IllegalStateException("Failed to create tray") + } + + sni.set_title(trayHandle, "Compose Tray") + sni.set_status(trayHandle, "Active") + sni.set_icon_by_path(trayHandle, iconPath) + sni.set_tooltip_title(trayHandle, tooltip) + sni.set_tooltip_subtitle(trayHandle, "") + + initializeCallbacks() + initializeTrayMenu() + initLatch.countDown() + + // Event processing ---------------------------------------------------------- + while (running.get()) { + sni.sni_process_events() + while (true) taskQueue.poll()?.invoke() ?: break + Thread.sleep(50) + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } catch (e: Exception) { + e.printStackTrace() + } finally { + cleanupTray() + } + }.apply { + name = "LinuxTray-Thread" + isDaemon = true + start() + } + + try { initLatch.await() } catch (_: InterruptedException) { Thread.currentThread().interrupt() } + } + + private fun initializeCallbacks() { + trayHandle?.let { handle -> + activateCallback = object : SNIWrapper.ActivateCallback { + override fun invoke(x: Int, y: Int, data: Pointer?) { + // Capture click position + TrayClickTracker.updateClickPosition(x, y) + debugln { "LinuxTrayManager: Tray clicked at position ($x, $y)" } + onLeftClick?.invoke() + } + } + sni.set_activate_callback(handle, activateCallback, null) + + secondaryActivateCallback = object : SNIWrapper.SecondaryActivateCallback { + override fun invoke(x: Int, y: Int, data: Pointer?) { + // Also capture right-click position + TrayClickTracker.updateClickPosition(x, y) + debugln { "LinuxTrayManager: Tray right-clicked at position ($x, $y)" } + /* secondary click */ + } + } + sni.set_secondary_activate_callback(handle, secondaryActivateCallback, null) + + scrollCallback = object : SNIWrapper.ScrollCallback { + override fun invoke(delta: Int, orientation: Int, data: Pointer?) { /* scroll */ } + } + sni.set_scroll_callback(handle, scrollCallback, null) + } + } + + private fun initializeTrayMenu() { + if (menuItems.isEmpty()) return + menuHandle = sni.create_menu() ?: run { + errorln { "Failed to create menu" } + return + } + menuItems.forEach { addNativeMenuItem(menuHandle!!, it) } + trayHandle?.let { sni.set_context_menu(it, menuHandle) } + } + + private fun addNativeMenuItem(parentMenu: Pointer, menuItem: MenuItem) { + when { + menuItem.text == "-" -> sni.add_menu_separator(parentMenu) + menuItem.subMenuItems.isNotEmpty() -> { + val submenu = sni.create_submenu(parentMenu, menuItem.text) + if (submenu != null) { + menuItemReferences[menuItem.text] = submenu + menuItem.subMenuItems.forEach { addNativeMenuItem(submenu, it) } + } + } + menuItem.isCheckable -> { + val cb = createActionCallback(menuItem) + val item = sni.add_checkable_menu_action(parentMenu, menuItem.text, if (menuItem.isChecked) 1 else 0, cb, null) + callbackReferences.add(cb) + item?.let { menuItemReferences[menuItem.text] = it } + } + else -> { + val cb = createActionCallback(menuItem) + val item = if (menuItem.isEnabled) { + sni.add_menu_action(parentMenu, menuItem.text, cb, null) + } else { + sni.add_disabled_menu_action(parentMenu, menuItem.text, cb, null) + } + callbackReferences.add(cb) + item?.let { menuItemReferences[menuItem.text] = it } + } + } + } + + private fun createActionCallback(menuItem: MenuItem): SNIWrapper.ActionCallback = + object : SNIWrapper.ActionCallback { + override fun invoke(data: Pointer?) { + if (running.get()) menuItem.onClick?.invoke() + } + } + + private fun cleanupTray() { + lock.withLock { + running.set(false) + activateCallback = null + secondaryActivateCallback = null + scrollCallback = null + callbackReferences.clear() + menuItemReferences.clear() + menuHandle?.let { sni.destroy_menu(it) } + trayHandle?.let { sni.destroy_handle(it) } + menuHandle = null + trayHandle = null + sni.shutdown_tray_system() + infoln { "LinuxTrayManager: Cleaning up tray resources" } + } + } + + fun stopTray() { + lock.withLock { if (!running.get()) return; running.set(false) } + sni.sni_stop_exec() + trayThread?.interrupt() + trayThread?.let { t -> + try { + t.join(5_000) + if (t.isAlive) { + warnln { "Warning: Tray thread did not terminate in time, forcing interrupt again" } + t.interrupt() + t.join(2_000) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + if (shutdownHook != null && Thread.currentThread() != shutdownHook) { + try { Runtime.getRuntime().removeShutdownHook(shutdownHook) } catch (_: IllegalStateException) {} + } + shutdownHook = null + } + } + trayThread = null + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/SNIWrapper.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/SNIWrapper.kt new file mode 100644 index 0000000..7eb471c --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/SNIWrapper.kt @@ -0,0 +1,79 @@ +package com.kdroid.composetray.lib.linux + +import com.sun.jna.* + +// JNA Interface for sni_wrapper library +interface SNIWrapper : Library { + companion object { + // Load the shared library (adjust the path and name as needed) + val INSTANCE: SNIWrapper = Native.load("tray", SNIWrapper::class.java) as SNIWrapper + } + + // Callback interfaces + interface ActivateCallback : Callback { + fun invoke(x: Int, y: Int, data: Pointer?) + } + + interface SecondaryActivateCallback : Callback { + fun invoke(x: Int, y: Int, data: Pointer?) + } + + interface ScrollCallback : Callback { + fun invoke(delta: Int, orientation: Int, data: Pointer?) + } + + interface ActionCallback : Callback { + fun invoke(data: Pointer?) + } + + // System tray initialization and cleanup + fun init_tray_system(): Int + fun shutdown_tray_system() + + // Tray creation and destruction + fun create_tray(id: String?): Pointer? + fun destroy_handle(handle: Pointer?) + + // Tray property setters + fun set_title(handle: Pointer?, title: String?) + fun set_status(handle: Pointer?, status: String?) + fun set_icon_by_name(handle: Pointer?, name: String?) + fun set_icon_by_path(handle: Pointer?, path: String?) + fun update_icon_by_path(handle: Pointer?, path: String?) + fun set_tooltip_title(handle: Pointer?, title: String?) + fun set_tooltip_subtitle(handle: Pointer?, subTitle: String?) + + // Menu creation and management + fun create_menu(): Pointer? + fun destroy_menu(menu_handle: Pointer?) + fun set_context_menu(handle: Pointer?, menu: Pointer?) + fun add_menu_action(menu_handle: Pointer?, text: String?, cb: ActionCallback?, data: Pointer?): Pointer? + fun add_disabled_menu_action(menu_handle: Pointer?, text: String?, cb: ActionCallback?, data: Pointer?): Pointer? + fun add_checkable_menu_action(menu_handle: Pointer?, text: String?, checked: Int, cb: ActionCallback?, data: Pointer?): Pointer? + fun add_menu_separator(menu_handle: Pointer?) + fun create_submenu(menu_handle: Pointer?, text: String?): Pointer? + fun set_menu_item_text(menu_item_handle: Pointer?, text: String?) + fun set_menu_item_enabled(menu_item_handle: Pointer?, enabled: Int) + fun set_menu_item_checked(menu_item_handle: Pointer?, checked: Int): Int + fun remove_menu_item(menu_handle: Pointer?, menu_item_handle: Pointer?) + + fun tray_update(handle: Pointer?) + + fun clear_menu(menu_handle: Pointer?) + + // Tray event callbacks + fun set_activate_callback(handle: Pointer?, cb: ActivateCallback?, data: Pointer?) + fun set_secondary_activate_callback(handle: Pointer?, cb: SecondaryActivateCallback?, data: Pointer?) + fun set_scroll_callback(handle: Pointer?, cb: ScrollCallback?, data: Pointer?) + + // Notifications + fun show_notification(handle: Pointer?, title: String?, msg: String?, iconName: String?, secs: Int) + + // Event loop management + fun sni_exec(): Int + fun sni_process_events() + fun sni_stop_exec() + + //Debug mode management + fun sni_set_debug_mode(enabled: Int) +} diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/AppIndicator.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/AppIndicator.kt deleted file mode 100644 index f60dfd3..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/AppIndicator.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.kdroid.composetray.lib.linux.appindicator - -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer - -internal interface AppIndicator : Library { - companion object { - val INSTANCE: AppIndicator = Native.load("appindicator3", AppIndicator::class.java) - } - - fun app_indicator_new(id: String, icon_name: String, category: Int): Pointer - fun app_indicator_set_status(indicator: Pointer, status: Int) - fun app_indicator_set_menu(indicator: Pointer, menu: Pointer) - - - fun app_indicator_set_icon_full(indicator: Pointer, icon_name: String, icon_desc: String?) - fun app_indicator_set_title(indicator: Pointer, title: String) // Tooltip -} - -internal object AppIndicatorCategory { - const val APPLICATION_STATUS = 0 -} - -internal object AppIndicatorStatus { - val PASSIVE: Int = 0 - const val ACTIVE = 1 -} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/GCallback.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/GCallback.kt deleted file mode 100644 index 9299d89..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/GCallback.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.kdroid.composetray.lib.linux.appindicator - -import com.sun.jna.Callback -import com.sun.jna.Pointer - -internal interface GCallback : Callback { - fun callback(widget: Pointer, data: Pointer?) -} diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/GObject.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/GObject.kt deleted file mode 100644 index 9382293..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/GObject.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.kdroid.composetray.lib.linux.appindicator - -import com.sun.jna.Callback -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer - -internal interface GObject : Library { - companion object { - val INSTANCE: GObject = Native.load("gobject-2.0", GObject::class.java) - } - - fun g_signal_connect_data( - instance: Pointer, - detailed_signal: String, - c_handler: Callback, - data: Pointer?, - destroy_data: Pointer?, - connect_flags: Int - ): Long -} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/Gtk.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/Gtk.kt deleted file mode 100644 index b25ce65..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/appindicator/Gtk.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.kdroid.composetray.lib.linux.appindicator - -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer - -internal interface Gtk : Library { - companion object { - val INSTANCE: Gtk = Native.load("gtk-3", Gtk::class.java) - } - - fun gtk_init(argc: Int, argv: Pointer) - fun gtk_main() - fun gtk_menu_new(): Pointer - fun gtk_menu_item_new_with_label(label: String): Pointer - fun gtk_menu_item_set_submenu(menu_item: Pointer, submenu: Pointer) - fun gtk_menu_shell_append(menu_shell: Pointer, child: Pointer) - fun gtk_widget_show_all(widget: Pointer) - fun gtk_main_quit() - fun gtk_separator_menu_item_new(): Pointer - fun gtk_check_menu_item_new_with_label(label: String): Pointer - fun gtk_check_menu_item_get_active(checkMenuItem: Pointer): Int - fun gtk_widget_set_sensitive(widget: Pointer, sensitive: Int) - fun gtk_widget_destroy(menu: Pointer) - fun gtk_widget_hide(get: Pointer?) - fun gtk_check_menu_item_set_active(checkMenuItem: Pointer, active: Boolean) - -} diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gdk/Gdk.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gdk/Gdk.kt deleted file mode 100644 index b7324a7..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gdk/Gdk.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.kdroid.composetray.lib.linux.gdk - -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer -import com.sun.jna.Structure - -internal interface Gdk : Library { - companion object { - val INSTANCE: Gdk = Native.load("gdk-3", Gdk::class.java) - } - - fun gdk_display_get_default(): Pointer? - fun gdk_display_get_default_seat(display: Pointer?): Pointer? - fun gdk_seat_get_pointer(seat: Pointer?): Pointer? - fun gdk_device_get_position(device: Pointer?, screen: Pointer?, x: IntArray, y: IntArray) - fun gdk_display_get_primary_monitor(display: Pointer?): Pointer? - fun gdk_monitor_get_geometry(monitor: Pointer?, geometry: GdkRectangle) -} - -class GdkRectangle : Structure() { - @JvmField var x: Int = 0 - @JvmField var y: Int = 0 - @JvmField var width: Int = 0 - @JvmField var height: Int = 0 - - override fun getFieldOrder(): List { - return listOf("x", "y", "width", "height") - } -} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gtkstatusicon/GtkStatusIcon.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gtkstatusicon/GtkStatusIcon.kt deleted file mode 100644 index 9559ae7..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gtkstatusicon/GtkStatusIcon.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.kdroid.composetray.lib.linux.gtkstatusicon - -import com.sun.jna.Callback -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Pointer - -interface GtkStatusIcon : Library { - companion object { - val INSTANCE: GtkStatusIcon = Native.load("gtk-3", GtkStatusIcon::class.java) - } - - fun gtk_status_icon_new_from_file(filename: String): Pointer - fun gtk_status_icon_set_visible(status_icon: Pointer, visible: Int) - fun gtk_status_icon_set_tooltip_text(status_icon: Pointer, tooltip: String) - fun gtk_status_icon_as_widget(status_icon: Pointer): Pointer - - // GObject signal connection - fun g_signal_connect_data( - instance: Pointer, - detailed_signal: String, - c_handler: Callback, - data: Pointer?, - destroy_data: Pointer?, - flags: Int - ): Pointer - - fun g_signal_handlers_disconnect_matched(statusIcon: Pointer, i: Int, i1: Int, nothing: Nothing?, nothing1: Nothing?, nothing2: Nothing?, nothing3: Nothing?) -} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gtkstatusicon/GtkStatusIconActivateCallback.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gtkstatusicon/GtkStatusIconActivateCallback.kt deleted file mode 100644 index 580b8bc..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/linux/gtkstatusicon/GtkStatusIconActivateCallback.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.kdroid.composetray.lib.linux.gtkstatusicon - -import com.sun.jna.Callback -import com.sun.jna.Pointer - -interface GtkStatusIconActivateCallback : Callback { - fun invoke(status_icon: Pointer?, event_button: Int, event_time: Int) -} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOSMenuBarThemeDetector.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOSMenuBarThemeDetector.kt new file mode 100644 index 0000000..8c29081 --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOSMenuBarThemeDetector.kt @@ -0,0 +1,52 @@ +package com.kdroid.composetray.lib.mac + +import com.sun.jna.Native +import java.util.function.Consumer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import com.kdroid.composetray.lib.mac.MacTrayManager.MacTrayLibrary + +// Removed kermit Logger import and usage +// private val logger = Logger.withTag("MacOSMenuBarThemeDetector") + +object MacOSMenuBarThemeDetector { + + private val trayLib: MacTrayLibrary = Native.load("MacTray", MacTrayLibrary::class.java) + + private val listeners: MutableSet> = ConcurrentHashMap.newKeySet() + + private val callbackExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "MacOS MenuBar Theme Detector Thread").apply { isDaemon = true } + } + + private val themeChangedCallback = object : MacTrayManager.ThemeCallback { + override fun invoke(isDark: Int) { + callbackExecutor.execute { + val dark = isDark != 0 + notifyListeners(dark) + } + } + } + + init { + trayLib.tray_set_theme_callback(themeChangedCallback) + } + + fun isDark(): Boolean { + return trayLib.tray_is_menu_dark() != 0 + } + + fun registerListener(listener: Consumer) { + listeners.add(listener) + // Notify with current state upon registration + listener.accept(isDark()) + } + + fun removeListener(listener: Consumer) { + listeners.remove(listener) + } + + private fun notifyListeners(isDark: Boolean) { + listeners.forEach { it.accept(isDark) } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt new file mode 100644 index 0000000..0879fc5 --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt @@ -0,0 +1,365 @@ +// Modified MacTrayManager.kt (add ThemeCallback and update MacTrayLibrary) +package com.kdroid.composetray.lib.mac + +import androidx.compose.runtime.mutableStateOf +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.Library +import com.sun.jna.Callback +import com.sun.jna.ptr.IntByReference +import kotlinx.coroutines.* +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class MacTrayManager( + private var iconPath: String, + private var tooltip: String = "", + onLeftClick: (() -> Unit)? = null +) { + private val trayLib: MacTrayLibrary = Native.load("MacTray", MacTrayLibrary::class.java) + private var tray: MacTray? = null + private val menuItems: MutableList = mutableListOf() + private val running = AtomicBoolean(false) + private val lock = ReentrantLock() + private var trayThread: Thread? = null + private val initLatch = CountDownLatch(1) + + // Coroutine scopes for callback handling + private var mainScope: CoroutineScope? = null + private var ioScope: CoroutineScope? = null + + // Maintain a reference to all callbacks to avoid GC + private val callbackReferences: MutableList = mutableListOf() + private val nativeMenuItemsReferences: MutableList = mutableListOf() + private val onLeftClickCallback = mutableStateOf(onLeftClick) + + // Top level MenuItem class + data class MenuItem( + val text: String, + val isEnabled: Boolean = true, + val isCheckable: Boolean = false, + val isChecked: Boolean = false, + val onClick: (() -> Unit)? = null, + val subMenuItems: List = emptyList() + ) + + fun addMenuItem(menuItem: MenuItem) { + lock.withLock { + menuItems.add(menuItem) + } + } + + // Update a menu item's checked state + fun updateMenuItemCheckedState(label: String, isChecked: Boolean) { + lock.withLock { + val index = menuItems.indexOfFirst { it.text == label } + if (index != -1) { + menuItems[index] = menuItems[index].copy(isChecked = isChecked) + // Recreate the menu to reflect changes + recreateMenu() + } + } + } + + // Update the tray with new properties and menu items + fun update(newIconPath: String, newTooltip: String, newOnLeftClick: (() -> Unit)?, newMenuItems: List? = null) { + lock.withLock { + if (!running.get() || tray == null) return + + // Update properties + val iconChanged = this.iconPath != newIconPath + val tooltipChanged = this.tooltip != newTooltip + val onLeftClickChanged = this.onLeftClickCallback.value != newOnLeftClick + + // Update icon path and tooltip + this.iconPath = newIconPath + this.tooltip = newTooltip + this.onLeftClickCallback.value = newOnLeftClick + + // Update the tray object with new values + tray?.let { + if (iconChanged) { + it.icon_filepath = newIconPath + } + if (tooltipChanged) { + it.tooltip = newTooltip + } + if (onLeftClickChanged) { + initializeOnLeftClickCallback() + } + } + + // Update menu items if provided + if (newMenuItems != null) { + menuItems.clear() + menuItems.addAll(newMenuItems) + recreateMenu() + } else if (iconChanged || tooltipChanged || onLeftClickChanged) { + // If any property changed but menu items didn't, still update the tray + trayLib.tray_update(tray!!) + } + } + } + + // Recreate the menu with updated state + private fun recreateMenu() { + if (!running.get() || tray == null) return + + // Clear old references + callbackReferences.clear() + nativeMenuItemsReferences.clear() + + // Recreate the menu + initializeTrayMenu() + + // Update the tray + trayLib.tray_update(tray!!) + } + + // Start the tray + fun startTray() { + lock.withLock { + if (running.get()) { + return + } + + running.set(true) + + // Create new coroutine scopes + mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Create and start the tray thread + trayThread = Thread { + try { + // Create tray structure + tray = MacTray().apply { + icon_filepath = iconPath + tooltip = this@MacTrayManager.tooltip + } + + initializeOnLeftClickCallback() + initializeTrayMenu() + + val initResult = trayLib.tray_init(tray!!) + if (initResult != 0) { + throw IllegalStateException("Failed to initialize tray: $initResult") + } + + // Signal that initialization is complete + initLatch.countDown() + + // Run the event loop + while (running.get()) { + val result = trayLib.tray_loop(0) + if (result != 0) { + break + } + Thread.sleep(100) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + cleanupTray() + } + }.apply { + name = "MacTray-Thread" + isDaemon = true + start() + } + + // Wait for initialization to complete + try { + initLatch.await() + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + } + + private fun initializeOnLeftClickCallback() { + val trayObj = tray ?: return + + if (onLeftClickCallback.value != null) { + trayObj.cb = object : TrayCallback { + override fun invoke(tray: Pointer?) { + mainScope?.launch { + ioScope?.launch { + onLeftClickCallback.value?.invoke() + } + } + } + } + callbackReferences.add(trayObj.cb!!) + } + } + + private fun initializeTrayMenu() { + val trayObj = tray ?: return + + if (menuItems.isEmpty()) { + return + } + + val menuItemPrototype = MacTrayMenuItem() + val nativeMenuItems = menuItemPrototype.toArray(menuItems.size + 1) as Array + + menuItems.forEachIndexed { index, item -> + val nativeItem = nativeMenuItems[index] + initializeNativeMenuItem(nativeItem, item) + nativeItem.write() + nativeMenuItemsReferences.add(nativeItem) + } + + // Last element to mark the end of the menu + nativeMenuItems[menuItems.size].text = null + nativeMenuItems[menuItems.size].write() + + trayObj.menu = nativeMenuItems[0].pointer + } + + private fun initializeNativeMenuItem(nativeItem: MacTrayMenuItem, menuItem: MenuItem) { + nativeItem.text = menuItem.text + nativeItem.disabled = if (menuItem.isEnabled) 0 else 1 + nativeItem.checked = if (menuItem.isChecked) 1 else 0 + + menuItem.onClick?.let { onClick -> + val callback = object : MenuItemCallback { + override fun invoke(item: Pointer?) { + if (!running.get()) return + + mainScope?.launch { + ioScope?.launch { + onClick() + // For checkable items, the onClick handler in MacTrayMenuBuilderImpl + // will call updateMenuItemCheckedState which will recreate the menu + } + } + } + } + nativeItem.cb = callback + callbackReferences.add(callback) + } + + if (menuItem.subMenuItems.isNotEmpty()) { + val subMenuPrototype = MacTrayMenuItem() + val subMenuItemsArray = subMenuPrototype.toArray(menuItem.subMenuItems.size + 1) as Array + + menuItem.subMenuItems.forEachIndexed { index, subItem -> + initializeNativeMenuItem(subMenuItemsArray[index], subItem) + subMenuItemsArray[index].write() + nativeMenuItemsReferences.add(subMenuItemsArray[index]) + } + + subMenuItemsArray[menuItem.subMenuItems.size].text = null + subMenuItemsArray[menuItem.subMenuItems.size].write() + nativeItem.submenu = subMenuItemsArray[0].pointer + } + } + + private fun cleanupTray() { + lock.withLock { + tray?.let { + try { + trayLib.tray_exit() + } catch (e: Exception) { + e.printStackTrace() + } + } + + // Clear all references + callbackReferences.clear() + nativeMenuItemsReferences.clear() + menuItems.clear() + tray = null + } + } + + fun stopTray() { + lock.withLock { + if (!running.get()) { + return + } + + running.set(false) + } + + // Wait for the tray thread to finish + trayThread?.let { thread -> + try { + thread.join(5000) // Wait up to 5 seconds + if (thread.isAlive) { + thread.interrupt() + } + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + + // Cancel coroutines + mainScope?.cancel() + ioScope?.cancel() + mainScope = null + ioScope = null + trayThread = null + } + + // Callback interfaces + interface TrayCallback : Callback { + fun invoke(tray: Pointer?) + } + + interface MenuItemCallback : Callback { + fun invoke(item: Pointer?) + } + + interface ThemeCallback : Callback { + fun invoke(isDark: Int) + } + + // JNA interface for the native library + interface MacTrayLibrary : Library { + fun tray_init(tray: MacTray): Int + fun tray_loop(blocking: Int): Int + fun tray_update(tray: MacTray) + fun tray_exit() + fun tray_set_theme_callback(cb: ThemeCallback) + fun tray_is_menu_dark(): Int + + fun tray_get_status_item_position(x: IntByReference, y: IntByReference): Int + + fun tray_get_status_item_region(): String? + } + + // Structure for a menu item + @Structure.FieldOrder("text", "disabled", "checked", "cb", "submenu") + class MacTrayMenuItem : Structure() { + @JvmField var text: String? = null + @JvmField var disabled: Int = 0 + @JvmField var checked: Int = 0 + @JvmField var cb: MenuItemCallback? = null + @JvmField var submenu: Pointer? = null + + override fun getFieldOrder(): List { + return listOf("text", "disabled", "checked", "cb", "submenu") + } + } + + // Structure for the tray + @Structure.FieldOrder("icon_filepath", "tooltip", "menu", "cb") + class MacTray : Structure() { + @JvmField var icon_filepath: String? = null + @JvmField var tooltip: String? = null + @JvmField var menu: Pointer? = null + @JvmField var cb: TrayCallback? = null + + override fun getFieldOrder(): List { + return listOf("icon_filepath", "tooltip", "menu", "cb") + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeTrayLibrary.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeTrayLibrary.kt index ac9fc40..8cdcefa 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeTrayLibrary.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsNativeTrayLibrary.kt @@ -10,7 +10,7 @@ internal interface WindowsNativeTrayLibrary : StdCallLibrary { fun tray_loop(blocking: Int): Int fun tray_update(tray: WindowsNativeTray) fun tray_exit() - fun tray_get_notification_icons_position(x: IntByReference, y: IntByReference) + fun tray_get_notification_icons_position(x: IntByReference, y: IntByReference) : Int fun tray_get_notification_icons_region(): String? } diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt index 4a353de..5e41d73 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt @@ -1,28 +1,58 @@ package com.kdroid.composetray.lib.windows -import androidx.compose.runtime.mutableStateOf + +import com.kdroid.composetray.utils.debugln import com.sun.jna.Native +import kotlinx.coroutines.* +import java.util.concurrent.CountDownLatch import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock internal class WindowsTrayManager( - iconPath: String, - tooltip: String = "", - onLeftClick: (() -> Unit)? = null + private var iconPath: String, + private var tooltip: String = "", + private var onLeftClick: (() -> Unit)? = null ) { private val trayLib: WindowsNativeTrayLibrary = Native.load("tray", WindowsNativeTrayLibrary::class.java) - private val tray: WindowsNativeTray = WindowsNativeTray() - private val menuItems: MutableList = mutableListOf() - private val running = AtomicBoolean(true) + private var tray: AtomicReference = AtomicReference(null) + private val running = AtomicBoolean(false) + private val initialized = AtomicBoolean(false) + private val updateLock = ReentrantLock() + private val initLatch = CountDownLatch(1) // Maintain a reference to all callbacks to avoid GC - private val callbackReferences: MutableList = mutableListOf() + private val callbackReferences: MutableList = mutableListOf() private val nativeMenuItemsReferences: MutableList = mutableListOf() - private val onLeftClickCallback = mutableStateOf(onLeftClick) - init { - tray.icon_filepath = iconPath - tray.tooltip = tooltip + // Keep a reference to the tray callback + private var trayCallback: WindowsNativeTray.TrayCallback? = null + + // Thread for running the tray (similar to macOS) + private var trayThread: Thread? = null + + // Coroutine scopes for callback handling + private var mainScope: CoroutineScope? = null + private var ioScope: CoroutineScope? = null + + // Queue for updates to be processed on the tray thread + private val updateQueue = mutableListOf() + private val updateQueueLock = Object() + + companion object { + private fun log(message: String) { + debugln { "[WindowsTrayManager] $message" } + } } + // Update request data class + private data class UpdateRequest( + val iconPath: String, + val tooltip: String, + val onLeftClick: (() -> Unit)?, + val menuItems: List + ) + // Top level MenuItem class data class MenuItem( val text: String, @@ -33,53 +63,258 @@ internal class WindowsTrayManager( val subMenuItems: List = emptyList() ) - fun addMenuItem(menuItem: MenuItem) { - synchronized(menuItems) { - menuItems.add(menuItem) + fun initialize(menuItems: List) { + log("initialize() called with ${menuItems.size} menu items") + updateLock.withLock { + if (initialized.get()) { + log("Already initialized, delegating to update()") + update(iconPath, tooltip, onLeftClick, menuItems) + return + } + + running.set(true) + + // Create coroutine scopes + mainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Create and start the tray thread + trayThread = Thread { + try { + log("Tray thread started") + + // Create tray structure on this thread + val newTray = WindowsNativeTray().apply { + icon_filepath = iconPath + tooltip = this@WindowsTrayManager.tooltip + } + + // Set up callbacks and menu on this thread + setupLeftClickCallback(newTray) + setupMenu(newTray, menuItems) + + // Initialize the tray on this thread + log("Calling tray_init() on tray thread") + val initResult = trayLib.tray_init(newTray) + log("tray_init() returned: $initResult") + + if (initResult != 0) { + throw RuntimeException("Failed to initialize tray: $initResult") + } + + tray.set(newTray) + initialized.set(true) + + // Signal that initialization is complete + initLatch.countDown() + + // Run the blocking message loop on this thread + runMessageLoop() + + } catch (e: Exception) { + log("Error in tray thread: ${e.message}") + e.printStackTrace() + initLatch.countDown() // Ensure latch is released even on error + } finally { + cleanupTray() + } + }.apply { + name = "WindowsTray-Thread" + isDaemon = false // Don't make it daemon so it can clean up properly + start() + } + + // Wait for initialization to complete + try { + initLatch.await() + } catch (e: InterruptedException) { + log("Interrupted while waiting for initialization") + e.printStackTrace() + } + } + } + + fun update(newIconPath: String, newTooltip: String, newOnLeftClick: (() -> Unit)?, newMenuItems: List) { + log("update() called - icon: $newIconPath, tooltip: $newTooltip, menuItems: ${newMenuItems.size}") + + if (!initialized.get()) { + log("Not initialized, calling initialize()") + iconPath = newIconPath + tooltip = newTooltip + onLeftClick = newOnLeftClick + initialize(newMenuItems) + return + } + + // Queue the update to be processed on the tray thread + synchronized(updateQueueLock) { + updateQueue.add(UpdateRequest(newIconPath, newTooltip, newOnLeftClick, newMenuItems)) + updateQueueLock.notify() } } - // Start the tray - fun startTray() { - synchronized(tray) { - initializeOnLeftClickCallback() - if (tray.menu == null) { - initializeTrayMenu() - require(trayLib.tray_init(tray) == 0) { "Échec de l'initialisation du tray ${trayLib.tray_init(tray)}" } - runTrayLoop() + private fun runMessageLoop() { + log("Entering message loop on tray thread") + var consecutiveErrors = 0 + + while (running.get()) { + try { + // Check for pending updates + processUpdateQueue() + + // Process Windows messages with blocking call for responsiveness + val result = trayLib.tray_loop(0) + + when (result) { + -1 -> { + log("tray_loop returned -1 (error or quit)") + if (running.get() && initialized.get()) { + consecutiveErrors++ + if (consecutiveErrors > 5) { + log("Too many consecutive errors, exiting loop") + break + } + Thread.sleep(100) + + // Try to recover + val currentTray = tray.get() + if (currentTray != null) { + try { + log("Attempting to recover tray...") + trayLib.tray_update(currentTray) + consecutiveErrors = 0 + } catch (e: Exception) { + log("Failed to recover: ${e.message}") + e.printStackTrace() + } + } + } else { + break + } + } + 0 -> { + // Normal operation + consecutiveErrors = 0 + } + else -> { + log("tray_loop returned unexpected value: $result") + consecutiveErrors = 0 + } + } + Thread.sleep(50) + } catch (e: Exception) { + log("Exception in message loop: ${e.message}") + if (running.get()) { + e.printStackTrace() + Thread.sleep(100) + } else { + break + } + } + } + log("Message loop ended") + } + + private fun processUpdateQueue() { + val update = synchronized(updateQueueLock) { + if (updateQueue.isNotEmpty()) { + updateQueue.removeAt(0) + } else { + null } } + + if (update != null) { + log("Processing update from queue") + performUpdate(update) + } + } + + private fun performUpdate(update: UpdateRequest) { + // Update properties + iconPath = update.iconPath + tooltip = update.tooltip + onLeftClick = update.onLeftClick + + // Clear old references + val oldCallbackCount = callbackReferences.size + callbackReferences.clear() + nativeMenuItemsReferences.clear() + log("Cleared $oldCallbackCount old callbacks") + + // Create a new tray structure + val newTray = WindowsNativeTray().apply { + icon_filepath = update.iconPath + tooltip = update.tooltip + } + + // Set up new callbacks and menu + setupLeftClickCallback(newTray) + setupMenu(newTray, update.menuItems) + + // Update the native tray + log("Calling tray_update()") + trayLib.tray_update(newTray) + log("tray_update() completed") + + // Update the reference + tray.set(newTray) } - private fun initializeOnLeftClickCallback() { - if (onLeftClickCallback.value != null) { - tray.cb = WindowsNativeTray.TrayCallback { - onLeftClickCallback.value?.invoke() + private fun setupLeftClickCallback(trayObj: WindowsNativeTray) { + trayCallback = if (onLeftClick != null) { + log("Setting up left click callback") + object : WindowsNativeTray.TrayCallback { + override fun invoke(tray: WindowsNativeTray) { + log("Left click callback invoked") + try { + // Execute callback in IO scope (like macOS) + mainScope?.launch { + ioScope?.launch { + onLeftClick?.invoke() + } + } + } catch (e: Exception) { + log("Error in left click callback: ${e.message}") + e.printStackTrace() + } + } } + } else { + log("No left click callback set") + null + } + trayObj.cb = trayCallback + if (trayCallback != null) { + callbackReferences.add(trayCallback!!) } } - private fun initializeTrayMenu() { + private fun setupMenu(trayObj: WindowsNativeTray, menuItems: List) { + if (menuItems.isEmpty()) { + log("No menu items to set up") + trayObj.menu = null + return + } + + log("Setting up ${menuItems.size} menu items") val menuItemPrototype = WindowsNativeTrayMenuItem() val nativeMenuItems = menuItemPrototype.toArray(menuItems.size + 1) as Array - synchronized(menuItems) { - menuItems.forEachIndexed { index, item -> - val nativeItem = nativeMenuItems[index] - initializeNativeMenuItem(nativeItem, item) - nativeItem.write() - nativeMenuItemsReferences.add(nativeItem) // Store reference to prevent GC - } + menuItems.forEachIndexed { index, item -> + val nativeItem = nativeMenuItems[index] + initializeNativeMenuItem(nativeItem, item) + nativeItem.write() + nativeMenuItemsReferences.add(nativeItem) } // Last element to mark the end of the menu nativeMenuItems[menuItems.size].text = null nativeMenuItems[menuItems.size].write() - tray.menu = nativeMenuItems[0].pointer + trayObj.menu = nativeMenuItems[0].pointer } - private fun initializeNativeMenuItem(nativeItem: WindowsNativeTrayMenuItem, menuItem: MenuItem) { nativeItem.text = menuItem.text nativeItem.disabled = if (menuItem.isEnabled) 0 else 1 @@ -87,58 +322,106 @@ internal class WindowsTrayManager( // Create the menu item callback menuItem.onClick?.let { onClick -> - val callback = StdCallCallback { item -> - synchronized(tray) { - if (running.get() && tray.menu != null) { - onClick() - if (menuItem.isCheckable) { - item.checked = if (item.checked == 0) 1 else 0 - item.write() - trayLib.tray_update(tray) + val callback = object : StdCallCallback { + override fun invoke(item: WindowsNativeTrayMenuItem) { + log("Menu item clicked: ${menuItem.text}") + try { + if (running.get()) { + // Execute callback in IO scope (like macOS) + mainScope?.launch { + ioScope?.launch { + onClick() + if (menuItem.isCheckable) { + // Queue an update to refresh the checked state + synchronized(updateQueueLock) { + // We need to update the menu with the new checked state + // This would require keeping track of menu state + // For now, the update will come from the application + } + } + } + } } + } catch (e: Exception) { + log("Error in menu item callback: ${e.message}") + e.printStackTrace() } } } nativeItem.cb = callback - callbackReferences.add(callback) // Store reference to prevent GC + callbackReferences.add(callback) } - // If the element has child elements + // Handle submenus if (menuItem.subMenuItems.isNotEmpty()) { val subMenuPrototype = WindowsNativeTrayMenuItem() - val subMenuItemsArray = - subMenuPrototype.toArray(menuItem.subMenuItems.size + 1) as Array + val subMenuItemsArray = subMenuPrototype.toArray(menuItem.subMenuItems.size + 1) as Array + menuItem.subMenuItems.forEachIndexed { index, subItem -> initializeNativeMenuItem(subMenuItemsArray[index], subItem) subMenuItemsArray[index].write() - nativeMenuItemsReferences.add(subMenuItemsArray[index]) // Store reference to prevent GC + nativeMenuItemsReferences.add(subMenuItemsArray[index]) } + // End marker subMenuItemsArray[menuItem.subMenuItems.size].text = null subMenuItemsArray[menuItem.subMenuItems.size].write() nativeItem.submenu = subMenuItemsArray[0].pointer } - } - //Tray loop - private fun runTrayLoop() { - try { - while (running.get()) { - if (trayLib.tray_loop(1) != 0) break - } - } catch (e: Exception) { - e.printStackTrace() - } finally { - synchronized(tray) { + private fun cleanupTray() { + if (initialized.get()) { + try { + log("Calling tray_exit()") trayLib.tray_exit() - // tray.menu?.let { trayLib.tray_free_menu(it) } + } catch (e: Exception) { + log("Error in tray_exit(): ${e.message}") + e.printStackTrace() } } + + // Clear all references + callbackReferences.clear() + nativeMenuItemsReferences.clear() + trayCallback = null + tray.set(null) + + initialized.set(false) } fun stopTray() { - running.set(false) - trayLib.tray_exit() + log("stopTray() called") + updateLock.withLock { + running.set(false) + + // Wake up the thread if it's waiting + synchronized(updateQueueLock) { + updateQueueLock.notify() + } + + // Wait for the tray thread to finish + trayThread?.let { thread -> + try { + thread.join(5000) // Wait up to 5 seconds + if (thread.isAlive) { + log("Tray thread still alive after 5 seconds, interrupting") + thread.interrupt() + } + } catch (e: InterruptedException) { + log("Interrupted while waiting for tray thread") + e.printStackTrace() + } + } + + // Cancel coroutines + mainScope?.cancel() + ioScope?.cancel() + mainScope = null + ioScope = null + trayThread = null + + log("Tray stopped and cleaned up") + } } -} +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/menu/api/TrayMenuBuilder.kt b/src/commonMain/kotlin/com/kdroid/composetray/menu/api/TrayMenuBuilder.kt index ea54743..1884494 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/menu/api/TrayMenuBuilder.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/menu/api/TrayMenuBuilder.kt @@ -1,52 +1,75 @@ package com.kdroid.composetray.menu.api - /** - * Interface for building tray menus in a platform-independent manner. - * Implementations of this interface allow the creation of tray menus - * with items, checkable items, submenus, and dividers, and provide a - * mechanism for disposing resources when the menu is no longer needed. - */ - interface TrayMenuBuilder { - /** - * Adds an item to the tray menu. - * - * @param label The text label for the menu item. - * @param isEnabled Indicates whether the menu item is enabled. Defaults to true. - * @param onClick Lambda function to be invoked when the menu item is clicked. Defaults to an empty lambda. - */ - fun Item(label: String, isEnabled: Boolean = true, onClick: () -> Unit = {}) +/** + * Interface for building tray menus in a platform-independent manner. + * Implementations of this interface allow the creation of tray menus + * with items, checkable items, submenus, and dividers, and provide a + * mechanism for disposing resources when the menu is no longer needed. + */ +interface TrayMenuBuilder { + /** + * Adds an item to the tray menu. + * + * @param label The text label for the menu item. + * @param isEnabled Indicates whether the menu item is enabled. Defaults to true. + * @param onClick Lambda function to be invoked when the menu item is clicked. Defaults to an empty lambda. + */ + fun Item(label: String, isEnabled: Boolean = true, onClick: () -> Unit = {}) - /** - * Adds a checkable item to the tray menu. - * - * @param label The text label for the checkable menu item. - * @param checked Indicates the initial checked state of the item. Defaults to false. - * @param isEnabled Determines if the checkable item is enabled. Defaults to true. - * @param onToggle A lambda function to handle the toggle action. The new checked state is passed as a parameter. - */ - fun CheckableItem(label: String, checked: Boolean = false, isEnabled: Boolean = true, onToggle: (Boolean) -> Unit) + /** + * Adds a checkable item to the tray menu. + * This follows Compose's idiomatic pattern for stateful components. + * + * @param label The text label for the checkable menu item. + * @param checked The current checked state of the item. + * @param onCheckedChange A lambda function called when the user toggles the item. The new checked state is passed as a parameter. + * @param isEnabled Determines if the checkable item is enabled. Defaults to true. + */ + fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true + ) - /** - * Adds a submenu to the tray menu. - * - * @param label The text label for the submenu. - * @param isEnabled Indicates whether the submenu is enabled. Defaults to true. - * @param submenuContent A lambda function defining the contents of the submenu. Can be null. - */ - fun SubMenu(label: String, isEnabled: Boolean = true, submenuContent: (TrayMenuBuilder.() -> Unit)?) + /** + * Adds a checkable item to the tray menu with the legacy API. + * @deprecated Use the new API with separate checked and onCheckedChange parameters for better Compose idiomaticity + */ + @Deprecated( + message = "Use CheckableItem with separate checked and onCheckedChange parameters", + replaceWith = ReplaceWith("CheckableItem(label, checked, onCheckedChange, isEnabled)") + ) + fun CheckableItem( + label: String, + checked: Boolean = false, + isEnabled: Boolean = true, + onToggle: (Boolean) -> Unit + ) { + // Delegate to the new API + CheckableItem(label, checked, onToggle, isEnabled) + } - /** - * Adds a visual separator (divider) to the tray menu. - * This method is used to group or separate menu items, providing better - * organization and clarity within the menu structure. - */ - fun Divider() + /** + * Adds a submenu to the tray menu. + * + * @param label The text label for the submenu. + * @param isEnabled Indicates whether the submenu is enabled. Defaults to true. + * @param submenuContent A lambda function defining the contents of the submenu. Can be null. + */ + fun SubMenu(label: String, isEnabled: Boolean = true, submenuContent: (TrayMenuBuilder.() -> Unit)?) - /** - * Disposes of the resources associated with the tray menu. - * This method should be called when the tray menu is no longer in use - * to release any system resources held by it. - */ - fun dispose() -} + /** + * Adds a visual separator (divider) to the tray menu. + * This method is used to group or separate menu items, providing better + * organization and clarity within the menu structure. + */ + fun Divider() + /** + * Disposes of the resources associated with the tray menu. + * This method should be called when the tray menu is no longer in use + * to release any system resources held by it. + */ + fun dispose() +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/AwtTrayMenuBuilderImpl.kt b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/AwtTrayMenuBuilderImpl.kt index 670d5aa..552b163 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/AwtTrayMenuBuilderImpl.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/AwtTrayMenuBuilderImpl.kt @@ -16,15 +16,21 @@ internal class AwtTrayMenuBuilderImpl(private val popupMenu: PopupMenu, private popupMenu.add(menuItem) } - override fun CheckableItem(label: String, checked:Boolean, isEnabled: Boolean, onToggle: (Boolean) -> Unit) { - var isChecked = checked - val checkableMenuItem = MenuItem(getCheckableLabel(label, isChecked)) + override fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean + ) { + var currentChecked = checked + val checkableMenuItem = MenuItem(getCheckableLabel(label, currentChecked)) checkableMenuItem.isEnabled = isEnabled checkableMenuItem.addActionListener { - isChecked = !isChecked - checkableMenuItem.label = getCheckableLabel(label, isChecked) - onToggle(isChecked) + val newChecked = !currentChecked + currentChecked = newChecked + checkableMenuItem.label = getCheckableLabel(label, newChecked) + onCheckedChange(newChecked) } popupMenu.add(checkableMenuItem) @@ -48,4 +54,4 @@ internal class AwtTrayMenuBuilderImpl(private val popupMenu: PopupMenu, private private fun getCheckableLabel(label: String, isChecked: Boolean): String { return if (isChecked) "✔ $label" else label } -} +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/LinuxTrayMenuBuilderImpl.kt b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/LinuxTrayMenuBuilderImpl.kt index 530ecdd..f7c9643 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/LinuxTrayMenuBuilderImpl.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/LinuxTrayMenuBuilderImpl.kt @@ -1,143 +1,110 @@ package com.kdroid.composetray.menu.impl -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.kdroid.composetray.lib.linux.appindicator.GCallback -import com.kdroid.composetray.lib.linux.appindicator.GObject -import com.kdroid.composetray.lib.linux.appindicator.Gtk import com.kdroid.composetray.menu.api.TrayMenuBuilder -import com.sun.jna.Pointer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class LinuxTrayMenuBuilderImpl(private val menu: Pointer) : TrayMenuBuilder { - var itemClickLabel by mutableStateOf("") - private set - - var checkableToggleState by mutableStateOf?>(null) - private set - - private val scope = CoroutineScope(Dispatchers.Default) - - private val callbacks = mutableListOf() - private val menuItems = mutableListOf() - private val subMenuBuilders = mutableListOf() - - // Mutex to ensure thread safety when adding callbacks and menu items - private val mutex = Mutex() +import com.kdroid.composetray.lib.linux.LinuxTrayManager +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class LinuxTrayMenuBuilderImpl( + private val iconPath: String, + private val tooltip: String = "", + private val onLeftClick: (() -> Unit)?, + private val primaryActionLabel: String, + private val trayManager: LinuxTrayManager? = null +) : TrayMenuBuilder { + private val menuItems = mutableListOf() + private val lock = ReentrantLock() + + // Maintain persistent references to prevent GC + private val persistentMenuItems = mutableListOf() override fun Item(label: String, isEnabled: Boolean, onClick: () -> Unit) { - val menuItem = Gtk.INSTANCE.gtk_menu_item_new_with_label(label) - Gtk.INSTANCE.gtk_menu_shell_append(menu, menuItem) - Gtk.INSTANCE.gtk_widget_set_sensitive(menuItem, if (isEnabled) 1 else 0) - - val callback = object : GCallback { - override fun callback(widget: Pointer, data: Pointer?) { - itemClickLabel = label - onClick() - } + lock.withLock { + val menuItem = LinuxTrayManager.MenuItem( + text = label, + isEnabled = isEnabled, + onClick = onClick + ) + menuItems.add(menuItem) + persistentMenuItems.add(menuItem) // Store reference } - - scope.launch { - mutex.withLock { - // Conserver une référence au callback pour éviter la collecte par le GC - callbacks.add(callback) - menuItems.add(menuItem) - } - } - - GObject.INSTANCE.g_signal_connect_data( - menuItem, - "activate", - callback, - null, - null, - 0 - ) } - override fun CheckableItem(label: String, checked: Boolean, isEnabled: Boolean, onToggle: (Boolean) -> Unit) { - val checkMenuItem = Gtk.INSTANCE.gtk_check_menu_item_new_with_label(label) - Gtk.INSTANCE.gtk_menu_shell_append(menu, checkMenuItem) - Gtk.INSTANCE.gtk_widget_set_sensitive(checkMenuItem, if (isEnabled) 1 else 0) - Gtk.INSTANCE.gtk_check_menu_item_set_active(checkMenuItem, checked) - - val callback = object : GCallback { - override fun callback(widget: Pointer, data: Pointer?) { - val active = Gtk.INSTANCE.gtk_check_menu_item_get_active(checkMenuItem) - val isActive = active != 0 - checkableToggleState = label to isActive - onToggle(isActive) - } - } - - scope.launch { - mutex.withLock { - // Conserver une référence au callback pour éviter la collecte par le GC - callbacks.add(callback) - menuItems.add(checkMenuItem) - } + override fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean + ) { + lock.withLock { + // Create a mutable reference to the current checked state + // This will be used in the onClick callback to get the current state + // instead of capturing the initial state + val initialChecked = checked + + val menuItem = LinuxTrayManager.MenuItem( + text = label, + isEnabled = isEnabled, + isCheckable = true, + isChecked = initialChecked, + onClick = { + lock.withLock { + // Find the current menu item to get its current state + val currentMenuItem = menuItems.find { it.text == label } + // Toggle based on the current state, not the initial state + val currentChecked = currentMenuItem?.isChecked ?: initialChecked + val newChecked = !currentChecked + + // Call the onCheckedChange callback with the new state + onCheckedChange(newChecked) + + // Update the tray manager to reflect the new state + trayManager?.updateMenuItemCheckedState(label, newChecked) + } + } + ) + menuItems.add(menuItem) + persistentMenuItems.add(menuItem) // Store reference } - - GObject.INSTANCE.g_signal_connect_data( - checkMenuItem, - "toggled", - callback, - null, - null, - 0 - ) } override fun SubMenu(label: String, isEnabled: Boolean, submenuContent: (TrayMenuBuilder.() -> Unit)?) { - val menuItem = Gtk.INSTANCE.gtk_menu_item_new_with_label(label) - Gtk.INSTANCE.gtk_menu_shell_append(menu, menuItem) - Gtk.INSTANCE.gtk_widget_set_sensitive(menuItem, if (isEnabled) 1 else 0) - - val submenu = Gtk.INSTANCE.gtk_menu_new() - if (submenuContent == null) return - val submenuBuilder = LinuxTrayMenuBuilderImpl(submenu).apply(submenuContent) - - // Propagation des états des sous-menus - scope.launch { - submenuBuilder.itemClickLabel.let { label -> - if (label.isNotEmpty()) { - itemClickLabel = label - } - } + val subMenuItems = mutableListOf() + if (submenuContent != null) { + val subMenuImpl = LinuxTrayMenuBuilderImpl( + iconPath, + tooltip, + onLeftClick, + primaryActionLabel, + trayManager = trayManager + ).apply(submenuContent) + subMenuItems.addAll(subMenuImpl.menuItems) } - - scope.launch { - submenuBuilder.checkableToggleState?.let { state -> - checkableToggleState = state - } - } - - scope.launch { - mutex.withLock { - subMenuBuilders.add(submenuBuilder) - menuItems.add(menuItem) - } + lock.withLock { + val subMenu = LinuxTrayManager.MenuItem( + text = label, + isEnabled = isEnabled, + subMenuItems = subMenuItems + ) + menuItems.add(subMenu) + persistentMenuItems.add(subMenu) // Store reference } - Gtk.INSTANCE.gtk_menu_item_set_submenu(menuItem, submenu) } override fun Divider() { - val separator = Gtk.INSTANCE.gtk_separator_menu_item_new() - Gtk.INSTANCE.gtk_menu_shell_append(menu, separator) - scope.launch { - mutex.withLock { - menuItems.add(separator) - } + lock.withLock { + val divider = LinuxTrayManager.MenuItem(text = "-") + menuItems.add(divider) + persistentMenuItems.add(divider) // Store reference } } override fun dispose() { - scope.cancel() + lock.withLock { + // Clear references when disposing + persistentMenuItems.clear() + } } -} + + fun build(): List = lock.withLock { menuItems.toList() } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/MacTrayMenuBuilderImpl.kt b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/MacTrayMenuBuilderImpl.kt new file mode 100644 index 0000000..d6bc6b8 --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/MacTrayMenuBuilderImpl.kt @@ -0,0 +1,99 @@ +package com.kdroid.composetray.menu.impl + +import com.kdroid.composetray.lib.mac.MacTrayManager +import com.kdroid.composetray.menu.api.TrayMenuBuilder +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class MacTrayMenuBuilderImpl( + private val iconPath: String, + private val tooltip: String = "", + private val onLeftClick: (() -> Unit)?, + private val trayManager: MacTrayManager? = null +) : TrayMenuBuilder { + private val menuItems = mutableListOf() + private val lock = ReentrantLock() + + // Maintain persistent references to prevent GC + private val persistentMenuItems = mutableListOf() + + override fun Item(label: String, isEnabled: Boolean, onClick: () -> Unit) { + lock.withLock { + val menuItem = MacTrayManager.MenuItem( + text = label, + isEnabled = isEnabled, + onClick = onClick + ) + menuItems.add(menuItem) + persistentMenuItems.add(menuItem) // Store reference to prevent GC + } + } + + override fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean + ) { + lock.withLock { + val menuItem = MacTrayManager.MenuItem( + text = label, + isEnabled = isEnabled, + isCheckable = true, + isChecked = checked, + onClick = { + lock.withLock { + // Toggle the checked state + val newChecked = !checked + onCheckedChange(newChecked) + + // Note: The actual visual update of the check mark + // will happen when the menu is recreated after the state change + } + } + ) + menuItems.add(menuItem) + persistentMenuItems.add(menuItem) // Store reference to prevent GC + } + } + + override fun SubMenu(label: String, isEnabled: Boolean, submenuContent: (TrayMenuBuilder.() -> Unit)?) { + val subMenuItems = mutableListOf() + if (submenuContent != null) { + val subMenuImpl = MacTrayMenuBuilderImpl( + iconPath, + tooltip, + onLeftClick = onLeftClick, + trayManager = trayManager + ).apply(submenuContent) + subMenuItems.addAll(subMenuImpl.menuItems) + } + lock.withLock { + val subMenu = MacTrayManager.MenuItem( + text = label, + isEnabled = isEnabled, + subMenuItems = subMenuItems + ) + menuItems.add(subMenu) + persistentMenuItems.add(subMenu) // Store reference to prevent GC + } + } + + override fun Divider() { + lock.withLock { + val divider = MacTrayManager.MenuItem(text = "-") + menuItems.add(divider) + persistentMenuItems.add(divider) // Store reference to prevent GC + } + } + + override fun dispose() { + lock.withLock { + // Just clear references when disposing + // The actual MacTrayManager instance is managed by MacTrayInitializer + persistentMenuItems.clear() + } + } + + fun build(): List = lock.withLock { menuItems.toList() } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/WindowsTrayMenuBuilderImpl.kt b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/WindowsTrayMenuBuilderImpl.kt index 164270c..afa63c1 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/WindowsTrayMenuBuilderImpl.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/menu/impl/WindowsTrayMenuBuilderImpl.kt @@ -28,27 +28,22 @@ internal class WindowsTrayMenuBuilderImpl( } } - override fun CheckableItem(label: String, checked: Boolean, isEnabled: Boolean, onToggle: (Boolean) -> Unit) { - var isChecked = checked // Initialise l'état checked - + override fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean + ) { lock.withLock { val menuItem = WindowsTrayManager.MenuItem( text = label, isEnabled = isEnabled, isCheckable = true, - isChecked = isChecked, + isChecked = checked, onClick = { - lock.withLock { - // Inverts the checked state - isChecked = !isChecked - onToggle(isChecked) - - // Updates the item in the menuItems list - val itemIndex = menuItems.indexOfFirst { it.text == label } - if (itemIndex != -1) { - menuItems[itemIndex] = menuItems[itemIndex].copy(isChecked = isChecked) - } - } + // Toggle the checked state + val newChecked = !checked + onCheckedChange(newChecked) } ) menuItems.add(menuItem) @@ -89,4 +84,4 @@ internal class WindowsTrayMenuBuilderImpl( } fun build(): List = lock.withLock { menuItems.toList() } -} +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt index 72456e5..5d5f979 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt @@ -1,23 +1,23 @@ package com.kdroid.composetray.tray.api +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +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 com.kdroid.composetray.menu.api.TrayMenuBuilder import com.kdroid.composetray.tray.impl.AwtTrayInitializer -import com.kdroid.composetray.tray.impl.LinuxTrayInitializer +import com.kdroid.composetray.tray.impl.LinuxSNITrayInitializer +import com.kdroid.composetray.tray.impl.MacTrayInitializer import com.kdroid.composetray.tray.impl.WindowsTrayInitializer -import com.kdroid.composetray.utils.ComposableIconUtils -import com.kdroid.composetray.utils.IconRenderProperties -import com.kdroid.composetray.utils.extractToTempIfDifferent -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.kdroid.kmplog.e -import io.github.kdroidfilter.platformtools.OperatingSystem.LINUX -import io.github.kdroidfilter.platformtools.OperatingSystem.MACOS -import io.github.kdroidfilter.platformtools.OperatingSystem.UNKNOWN -import io.github.kdroidfilter.platformtools.OperatingSystem.WINDOWS +import com.kdroid.composetray.utils.* +import io.github.kdroidfilter.platformtools.OperatingSystem.* import io.github.kdroidfilter.platformtools.getOperatingSystem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,57 +32,62 @@ internal class NativeTray { private val awtTrayUsed = AtomicBoolean(false) - /** - * Constructor that accepts file paths for icons - * @deprecated Use the constructor with composable icon content instead - */ - @Deprecated( - message = "Use the constructor with composable icon content instead", - replaceWith = ReplaceWith("NativeTray(iconContent, tooltip, primaryAction, primaryActionLabel, menuContent)") - ) - constructor( + private val os = getOperatingSystem() + private var initialized = false + + fun update( iconPath: String, windowsIconPath: String = iconPath, - tooltip: String = "", - primaryAction: (() -> Unit)?, - primaryActionLabel: String, - menuContent: (TrayMenuBuilder.() -> Unit)? = null - ) { - initializeTray(iconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) - } - - /** - * Constructor that accepts a Composable for the icon - */ - constructor( - iconContent: @Composable () -> Unit, - iconRenderProperties: IconRenderProperties = IconRenderProperties(), - tooltip: String = "", + tooltip: String, primaryAction: (() -> Unit)?, primaryActionLabel: String, - menuContent: (TrayMenuBuilder.() -> Unit)? = null + menuContent: (TrayMenuBuilder.() -> Unit)? ) { - // Render the composable to PNG file for general use - val pngIconPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) - Log.d("NativeTray", "Generated PNG icon path: $pngIconPath") + if (!initialized) { + initializeTray(iconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + initialized = true + return + } - // For Windows, we need an ICO file - val windowsIconPath = if (getOperatingSystem() == WINDOWS) { - // Create a temporary ICO file - ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent).also { - Log.d("NativeTray", "Generated Windows ICO path: $it") + try { + when (os) { + LINUX -> LinuxSNITrayInitializer.update(iconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + WINDOWS -> WindowsTrayInitializer.update(windowsIconPath, tooltip, primaryAction, menuContent) + MACOS -> MacTrayInitializer.update(iconPath, tooltip, primaryAction, menuContent) + UNKNOWN -> { + AwtTrayInitializer.update(iconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + awtTrayUsed.set(true) + } + else -> {} } - } else { - pngIconPath + } catch (th: Throwable) { + errorln { "NativeTray: Error updating tray: $th" } } + } - initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + fun dispose() { + when (os) { + LINUX -> LinuxSNITrayInitializer.dispose() + WINDOWS -> WindowsTrayInitializer.dispose() + MACOS -> MacTrayInitializer.dispose() + UNKNOWN -> if (awtTrayUsed.get()) AwtTrayInitializer.dispose() + else -> {} + } + initialized = false } + /** + * Constructor that accepts file paths for icons + * @deprecated Use the constructor with composable icon content instead + */ + @Deprecated( + message = "Use the constructor with composable icon content instead", + replaceWith = ReplaceWith("NativeTray(iconContent, tooltip, primaryAction, primaryActionLabel, menuContent)") + ) private fun initializeTray( iconPath: String, - windowsIconPath: String, - tooltip: String, + windowsIconPath: String = iconPath, + tooltip: String = "", primaryAction: (() -> Unit)?, primaryActionLabel: String, menuContent: (TrayMenuBuilder.() -> Unit)? = null @@ -93,49 +98,74 @@ internal class NativeTray { try { when (os) { LINUX -> { - Log.d("NativeTray", "Initializing Linux tray with icon path: $iconPath") - LinuxTrayInitializer.initialize(iconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + debugln { "NativeTray: Initializing Linux tray with icon path: $iconPath" } + LinuxSNITrayInitializer.initialize(iconPath, tooltip, primaryAction, primaryActionLabel, menuContent) trayInitialized = true } WINDOWS -> { - Log.d("NativeTray", "Initializing Windows tray with icon path: $windowsIconPath") + debugln { "NativeTray: Initializing Windows tray with icon path: $windowsIconPath" } WindowsTrayInitializer.initialize(windowsIconPath, tooltip, primaryAction, menuContent) trayInitialized = true } + MACOS -> { + debugln { "NativeTray: Initializing macOS tray with icon path: $iconPath" } + MacTrayInitializer.initialize(iconPath, tooltip, primaryAction, menuContent) + trayInitialized = true + } + else -> {} } } catch (th: Throwable) { - Log.e("NativeTray", "Error initializing tray:", th) + errorln { "NativeTray: Error initializing tray: $th" } } - val awtTrayRequired = os == MACOS || os == UNKNOWN || !trayInitialized + val awtTrayRequired = os == UNKNOWN || !trayInitialized if (awtTrayRequired) { if (AwtTrayInitializer.isSupported()) { try { - Log.d("NativeTray", "Initializing AWT tray with icon path: $iconPath") + debugln { "NativeTray: Initializing AWT tray with icon path: $iconPath" } AwtTrayInitializer.initialize(iconPath, tooltip, primaryAction, primaryActionLabel, menuContent) awtTrayUsed.set(true) } catch (th: Throwable) { - Log.e("NativeTray", "Error initializing AWT tray:", th) + errorln { "NativeTray: Error initializing AWT tray: $th" } } } else { - Log.d("NativeTray", "AWT tray is not supported") + debugln { "NativeTray: AWT tray is not supported" } } } } } - internal fun dispose() { - val os = getOperatingSystem() - when { - awtTrayUsed.get() -> AwtTrayInitializer.dispose() - os == LINUX -> LinuxTrayInitializer.dispose() - os == WINDOWS -> WindowsTrayInitializer.dispose() - else -> {} + /** + * Constructor that accepts a Composable for the icon + */ + private fun initializeTray( + iconContent: @Composable () -> Unit, + iconRenderProperties: IconRenderProperties = IconRenderProperties(), + tooltip: String = "", + primaryAction: (() -> Unit)?, + primaryActionLabel: String, + menuContent: (TrayMenuBuilder.() -> Unit)? = null + ) { + // Render the composable to PNG file for general use + val pngIconPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) + debugln { "NativeTray: Generated PNG icon path: $pngIconPath" } + + // For Windows, we need an ICO file + val windowsIconPath = if (getOperatingSystem() == WINDOWS) { + // Create a temporary ICO file + ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent).also { + debugln { "NativeTray: Generated Windows ICO path: $it" } + } + } else { + pngIconPath } + + initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) } + } @@ -148,7 +178,7 @@ internal class NativeTray { * @param primaryAction An optional callback to be invoked when the tray icon is clicked (handled only on specific platforms). * @param primaryActionLabel The label for the primary action on Linux and macOS. Defaults to "Open". * @param menuContent A lambda that builds the tray menu using a `TrayMenuBuilder`. Define the menu structure, including items, checkable items, dividers, and submenus. - * + * * @deprecated Use the version with composable icon content instead */ @Deprecated( @@ -162,32 +192,28 @@ fun ApplicationScope.Tray( tooltip: String, primaryAction: (() -> Unit)? = null, primaryActionLabel: String = "Open", - menuContent: (TrayMenuBuilder.() -> Unit)? = null + menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { val absoluteIconPath = remember(iconPath) { extractToTempIfDifferent(iconPath)?.absolutePath.orEmpty() } val absoluteWindowsIconPath = remember(iconPath, windowsIconPath) { if (windowsIconPath == iconPath) absoluteIconPath else extractToTempIfDifferent(windowsIconPath)?.absolutePath.orEmpty() } - DisposableEffect( - absoluteIconPath, - absoluteWindowsIconPath, - tooltip, - primaryAction, - primaryActionLabel, - menuContent - ) { - val tray = NativeTray( - iconPath = absoluteIconPath, - windowsIconPath = absoluteWindowsIconPath, - tooltip = tooltip, - primaryAction = primaryAction, - primaryActionLabel = primaryActionLabel, - menuContent = menuContent - ) + val tray = remember { NativeTray() } + + // Calculate menu hash to detect changes + val menuHash = MenuContentHash.calculateMenuHash(menuContent) + + // Update when params change, including menuHash + LaunchedEffect(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent, menuHash) { + tray.update(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + } + + // Dispose only when Tray is removed from composition + DisposableEffect(Unit) { onDispose { - Log.d("NativeTray", "onDispose") + debugln { "NativeTray: onDispose" } tray.dispose() } } @@ -213,30 +239,158 @@ fun ApplicationScope.Tray( primaryActionLabel: String = "Open", menuContent: (TrayMenuBuilder.() -> Unit)? = null, ) { - // Calculate a hash of the rendered composable content to detect changes - val contentHash = ComposableIconUtils.calculateContentHash(iconRenderProperties, iconContent) - - DisposableEffect( - iconContent, - iconRenderProperties, - tooltip, - primaryAction, - primaryActionLabel, - menuContent, - contentHash, // Use the content hash as an implicit key - ) { - val tray = NativeTray( - iconContent = iconContent, - iconRenderProperties = iconRenderProperties, - tooltip = tooltip, - primaryAction = primaryAction, - primaryActionLabel = primaryActionLabel, - menuContent = menuContent, + val isDark = isMenuBarInDarkMode() // Observe menu bar theme to trigger recomposition on changes + + val os = getOperatingSystem() + // Calculate a hash of the rendered composable content to detect changes, including theme state + val contentHash = ComposableIconUtils.calculateContentHash(iconRenderProperties, iconContent) + isDark.hashCode() + + // Calculate a hash of the menu content to detect changes + val menuHash = MenuContentHash.calculateMenuHash(menuContent) + + val pngIconPath = remember(contentHash) { ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) } + val windowsIconPath = remember(contentHash) { + if (os == WINDOWS) ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent) else pngIconPath + } + + val tray = remember { NativeTray() } + + // Update when params change, including contentHash (which incorporates theme) + LaunchedEffect(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent, contentHash, menuHash) { + tray.update(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + } + + // Dispose only when Tray is removed from composition + DisposableEffect(Unit) { + onDispose { + debugln { "NativeTray: onDispose" } + tray.dispose() + } + } +} + +/** + * Configures and displays a system tray icon using an ImageVector, with automatic tint adaptation based on menu bar theme. + * + * @param icon The ImageVector to display as the tray icon. + * @param tint Optional tint color for the icon. If null, automatically adapts to white in dark mode and black in light mode. + * @param iconRenderProperties Properties for rendering the icon. + * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon. + * @param primaryAction An optional callback to be invoked when the tray icon is clicked. + * @param primaryActionLabel The label for the primary action on Linux and macOS. Defaults to "Open". + * @param menuContent A lambda that builds the tray menu. + */ +@Composable +fun ApplicationScope.Tray( + icon: ImageVector, + tint: Color? = null, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + primaryActionLabel: String = "Open", + menuContent: (TrayMenuBuilder.() -> Unit)? = null, +) { + val isDark = isMenuBarInDarkMode() + + // Define the icon content lambda + val iconContent: @Composable () -> Unit = { + Image( + imageVector = icon, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + colorFilter = tint?.let { androidx.compose.ui.graphics.ColorFilter.tint(it) } + ?: if (isDark) androidx.compose.ui.graphics.ColorFilter.tint(Color.White) + else androidx.compose.ui.graphics.ColorFilter.tint(Color.Black) ) + } + + val os = getOperatingSystem() + // Calculate menu hash to detect changes + val menuHash = MenuContentHash.calculateMenuHash(menuContent) + + // Updated contentHash to include icon and tint for proper recomposition on changes + val contentHash = ComposableIconUtils.calculateContentHash(iconRenderProperties, iconContent) + + isDark.hashCode() + + icon.hashCode() + + (tint?.hashCode() ?: 0) // Include tint if set; 0 as fallback when null + + val pngIconPath = remember(contentHash) { ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) } + val windowsIconPath = remember(contentHash) { + if (os == WINDOWS) ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent) else pngIconPath + } + val tray = remember { NativeTray() } + + // Update when params change, including contentHash (which incorporates theme/icon/tint) + LaunchedEffect(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent, contentHash, menuHash) { + tray.update(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + } + + // Dispose only when Tray is removed from composition + DisposableEffect(Unit) { onDispose { - Log.d("NativeTray", "onDispose") + debugln { "NativeTray: onDispose" } tray.dispose() } } } + +/** + * Configures and displays a system tray icon using a Painter. + * + * @param icon The Painter to display as the tray icon. + * @param iconRenderProperties Properties for rendering the icon. + * @param tooltip The tooltip text to be displayed when the user hovers over the tray icon. + * @param primaryAction An optional callback to be invoked when the tray icon is clicked. + * @param primaryActionLabel The label for the primary action on Linux and macOS. Defaults to "Open". + * @param menuContent A lambda that builds the tray menu. + */ +@Composable +fun ApplicationScope.Tray( + icon: Painter, + iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(), + tooltip: String, + primaryAction: (() -> Unit)? = null, + primaryActionLabel: String = "Open", + menuContent: (TrayMenuBuilder.() -> Unit)? = null, +) { + val isDark = isMenuBarInDarkMode() // Included for consistency, even if not used in rendering + + // Define the icon content lambda + val iconContent: @Composable () -> Unit = { + Image( + painter = icon, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + + val os = getOperatingSystem() + // Calculate menu hash to detect changes + val menuHash = MenuContentHash.calculateMenuHash(menuContent) + + // Updated contentHash to include icon for proper recomposition on changes + val contentHash = ComposableIconUtils.calculateContentHash(iconRenderProperties, iconContent) + + isDark.hashCode() + + icon.hashCode() + + val pngIconPath = remember(contentHash) { ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, iconContent) } + val windowsIconPath = remember(contentHash) { + if (os == WINDOWS) ComposableIconUtils.renderComposableToIcoFile(iconRenderProperties, iconContent) else pngIconPath + } + + val tray = remember { NativeTray() } + + // Update when params change, including contentHash (which incorporates theme/icon) + LaunchedEffect(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent, contentHash, menuHash) { + tray.update(pngIconPath, windowsIconPath, tooltip, primaryAction, primaryActionLabel, menuContent) + } + + // Dispose only when Tray is removed from composition + DisposableEffect(Unit) { + onDispose { + debugln { "NativeTray: onDispose" } + tray.dispose() + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/AwtTrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/AwtTrayInitializer.kt index 92ec5d2..9631867 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/AwtTrayInitializer.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/AwtTrayInitializer.kt @@ -95,6 +95,17 @@ object AwtTrayInitializer { trayIcon = newTrayIcon } + fun update( + iconPath: String, + tooltip: String, + onLeftClick: (() -> Unit)?, + primaryActionLabel: String = "Open", + menuContent: (TrayMenuBuilder.() -> Unit)? + ) { + dispose() + initialize(iconPath, tooltip, onLeftClick, primaryActionLabel, menuContent) + } + /** * Disposes of the current tray icon, if it exists. */ @@ -104,4 +115,4 @@ object AwtTrayInitializer { trayIcon = null } } -} +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/LinuxSNITrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/LinuxSNITrayInitializer.kt new file mode 100644 index 0000000..073d05b --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/LinuxSNITrayInitializer.kt @@ -0,0 +1,116 @@ +package com.kdroid.composetray.tray.impl + +import com.kdroid.composetray.lib.linux.LinuxTrayManager +import com.kdroid.composetray.lib.linux.SNIWrapper +import com.kdroid.composetray.menu.api.TrayMenuBuilder +import com.kdroid.composetray.menu.impl.LinuxTrayMenuBuilderImpl +import com.kdroid.composetray.utils.allowComposeNativeTrayLogging +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +object LinuxSNITrayInitializer { + + private var trayMenuImpl: LinuxTrayMenuBuilderImpl? = null + private var linuxTrayManager: LinuxTrayManager? = null + private val lock = ReentrantLock() + + init { + val debugMode = allowComposeNativeTrayLogging + try { + SNIWrapper.INSTANCE.sni_set_debug_mode(if (debugMode) 1 else 0) + } catch (e: Exception) { + } + + } + + fun initialize( + iconPath: String, + tooltip: String, + onLeftClick: (() -> Unit)? = null, + primaryActionLabel: String, + menuContent: (TrayMenuBuilder.() -> Unit)? = null + ) { + lock.withLock { + if (linuxTrayManager == null) { + // Create a new instance of LinuxTrayManager if it doesn't exist + linuxTrayManager = LinuxTrayManager(iconPath, tooltip, onLeftClick, primaryActionLabel) + + // Create an instance of LinuxTrayMenuBuilderImpl and apply the menu content + if (menuContent != null) { + trayMenuImpl = LinuxTrayMenuBuilderImpl( + iconPath, + tooltip, + onLeftClick, + primaryActionLabel, + trayManager = linuxTrayManager // Pass the tray manager reference + ).apply { + menuContent() + } + val menuItems = trayMenuImpl!!.build() + + // Add each menu item to LinuxTrayManager + menuItems.forEach { linuxTrayManager!!.addMenuItem(it) } + } + + // Start the Linux tray (this will initialize and run the event loop in its own thread) + linuxTrayManager!!.startTray() + } else { + // Update the existing tray manager + update(iconPath, tooltip, onLeftClick, primaryActionLabel, menuContent) + } + } + } + + fun update( + iconPath: String, + tooltip: String, + onLeftClick: (() -> Unit)? = null, + primaryActionLabel: String, + menuContent: (TrayMenuBuilder.() -> Unit)? = null + ) { + lock.withLock { + if (linuxTrayManager == null) { + // If tray manager doesn't exist, initialize it + initialize(iconPath, tooltip, onLeftClick, primaryActionLabel, menuContent) + return + } + + // Create a new menu builder and build the menu items + val newMenuItems = if (menuContent != null) { + val newTrayMenuImpl = LinuxTrayMenuBuilderImpl( + iconPath, + tooltip, + onLeftClick, + primaryActionLabel, + trayManager = linuxTrayManager + ).apply { + menuContent() + } + + // Store the new menu builder + trayMenuImpl?.dispose() + trayMenuImpl = newTrayMenuImpl + + // Build the menu items + newTrayMenuImpl.build() + } else { + null + } + + // Update the tray manager with new properties and menu items + linuxTrayManager!!.update(iconPath, tooltip, onLeftClick, primaryActionLabel, newMenuItems) + } + } + + fun dispose() { + lock.withLock { + // Stop the tray manager + linuxTrayManager?.stopTray() + linuxTrayManager = null + + // Clear menu implementation + trayMenuImpl?.dispose() + trayMenuImpl = null + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt deleted file mode 100644 index 5f9d79b..0000000 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/LinuxTrayInitializer.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.kdroid.composetray.tray.impl - -import com.kdroid.composetray.lib.linux.appindicator.AppIndicator -import com.kdroid.composetray.lib.linux.appindicator.AppIndicatorCategory -import com.kdroid.composetray.lib.linux.appindicator.AppIndicatorStatus -import com.kdroid.composetray.lib.linux.appindicator.Gtk -import com.kdroid.composetray.lib.linux.gdk.Gdk -import com.kdroid.composetray.lib.linux.gdk.GdkRectangle -import com.kdroid.composetray.lib.linux.gtkstatusicon.GtkStatusIcon -import com.kdroid.composetray.lib.linux.gtkstatusicon.GtkStatusIconActivateCallback -import com.kdroid.composetray.menu.api.TrayMenuBuilder -import com.kdroid.composetray.menu.impl.LinuxTrayMenuBuilderImpl -import com.kdroid.composetray.utils.convertPositionToCorner -import com.kdroid.composetray.utils.saveTrayPosition -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.sun.jna.Pointer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicReference - -object LinuxTrayInitializer { - private var currentIndicator = AtomicReference(null) - private var currentStatusIcon = AtomicReference(null) - private var currentMenu = AtomicReference(null) - private var currentMenuBuilder = AtomicReference(null) - private var currentCallback = AtomicReference(null) - private val scope = CoroutineScope(Dispatchers.Default) - private var isInitialized = AtomicReference(false) - - private var instanceCount = 0 - - fun dispose() { - scope.launch { - try { - // Clean the menu builder - currentMenuBuilder.get()?.dispose() - currentMenuBuilder.set(null) - // Clean the indicator - currentIndicator.get()?.let { indicator -> - // Deactivate the indicator before destroying it - AppIndicator.INSTANCE.app_indicator_set_status(indicator, AppIndicatorStatus.PASSIVE) - // Ensure the menu is destroyed before continuing - Gtk.INSTANCE.gtk_widget_hide(currentMenu.get()) - currentIndicator.set(null) - } - // Clean the status icon - currentStatusIcon.get()?.let { statusIcon -> - GtkStatusIcon.INSTANCE.gtk_status_icon_set_visible(statusIcon, 0) - currentCallback.get()?.let { callback -> - // Disconnect the signal - GtkStatusIcon.INSTANCE.g_signal_handlers_disconnect_matched( - statusIcon, - 0, - 0, - null, - null, - null, - null - ) - } - currentStatusIcon.set(null) - currentCallback.set(null) - } - // Clean the menu - currentMenu.get()?.let { menu -> - Gtk.INSTANCE.gtk_widget_destroy(menu) - currentMenu.set(null) - } - // Force a small delay to ensure everything is cleaned - Thread.sleep(100) - // Quit the GTK main loop if it is running - if (isInitialized.get()) { - Gtk.INSTANCE.gtk_main_quit() - isInitialized.set(false) - } - } catch (e: Exception) { - Log.d("LinuxTrayInitializer", "Error during dispose: ${e.message}") - } - } - } - - fun initialize( - iconPath: String, - tooltip: String, - primaryAction: (() -> Unit)?, - primaryActionLabel: String, - menuContent: (TrayMenuBuilder.() -> Unit)? - ) { - cleanPreviousInstance() - initializeGtk() - - if (menuContent != null) { - initializeWithMenu( - currentInstanceId = generateCurrentInstanceId(), - iconPath = iconPath, - tooltip = tooltip, - primaryAction = primaryAction, - primaryActionLabel = primaryActionLabel, - menuContent = menuContent - ) - } else if (primaryAction != null) { - initializeWithoutMenu(iconPath, primaryAction) - } else { - Log.d("LinuxTrayInitializer", "No menu content or primary action provided for tray icon.") - } - - isInitialized.set(true) - startGtkMainLoopIfInitialized() - } - - private fun cleanPreviousInstance() { - if (isInitialized.get()) { - dispose() - // Attendre que le nettoyage soit terminé - Thread.sleep(200) - } - instanceCount++ - } - - private fun generateCurrentInstanceId(): String { - return "compose-tray-$instanceCount" - } - - private fun initializeGtk() { - if (!isInitialized.get()) { - Gtk.INSTANCE.gtk_init(0, Pointer.createConstant(0)) - } - } - - private fun initializeWithMenu( - currentInstanceId: String, - iconPath: String, - tooltip: String, - primaryAction: (() -> Unit)?, - primaryActionLabel: String, - menuContent: (TrayMenuBuilder.() -> Unit)? - ) { - try { - val indicator = AppIndicator.INSTANCE.app_indicator_new( - currentInstanceId, - iconPath, - AppIndicatorCategory.APPLICATION_STATUS - ) - currentIndicator.set(indicator) - - val menu = Gtk.INSTANCE.gtk_menu_new() - currentMenu.set(menu) - - val trayMenuBuilder = LinuxTrayMenuBuilderImpl(menu) - currentMenuBuilder.set(trayMenuBuilder) - - primaryAction?.let { - addPrimaryActionMenuItem(trayMenuBuilder, it, primaryActionLabel) - } - - trayMenuBuilder.apply(menuContent!!) - Gtk.INSTANCE.gtk_widget_show_all(menu) - - AppIndicator.INSTANCE.app_indicator_set_title(indicator, tooltip) - AppIndicator.INSTANCE.app_indicator_set_menu(indicator, menu) - AppIndicator.INSTANCE.app_indicator_set_status(indicator, AppIndicatorStatus.ACTIVE) - } catch (e: Exception) { - Log.d("LinuxTrayInitializer", "Error initializing AppIndicator: ${e.message}") - dispose() - } - } - - private fun addPrimaryActionMenuItem( - trayMenuBuilder: LinuxTrayMenuBuilderImpl, - primaryAction: () -> Unit, - primaryActionLabel: String - ) { - trayMenuBuilder.Item(primaryActionLabel) { - saveTrayIconPosition() - primaryAction.invoke() - } - } - - private fun saveTrayIconPosition() { - val display = Gdk.INSTANCE.gdk_display_get_default() - val seat = Gdk.INSTANCE.gdk_display_get_default_seat(display) - val pointer = Gdk.INSTANCE.gdk_seat_get_pointer(seat) - val x = IntArray(1) - val y = IntArray(1) - Gdk.INSTANCE.gdk_device_get_position(pointer, null, x, y) - - val monitor = Gdk.INSTANCE.gdk_display_get_primary_monitor(display) - val geometry = GdkRectangle() - Gdk.INSTANCE.gdk_monitor_get_geometry(monitor, geometry) - val width = geometry.width - val height = geometry.height - - saveTrayPosition(convertPositionToCorner(x[0], y[0], width, height)) - Log.d("LinuxTrayInitializer", "TrayPosition : ${convertPositionToCorner(x[0], y[0], width, height)}") - } - - private fun initializeWithoutMenu(iconPath: String, primaryAction: () -> Unit) { - try { - val statusIcon = GtkStatusIcon.INSTANCE.gtk_status_icon_new_from_file(iconPath) - currentStatusIcon.set(statusIcon) - - val callback = object : GtkStatusIconActivateCallback { - override fun invoke(status_icon: Pointer?, event_button: Int, event_time: Int) { - primaryAction.invoke() - } - } - currentCallback.set(callback) - - GtkStatusIcon.INSTANCE.g_signal_connect_data( - statusIcon, - "activate", - callback, - null, - null, - 0 - ) - GtkStatusIcon.INSTANCE.gtk_status_icon_set_visible(statusIcon, 1) - } catch (e: Exception) { - Log.d("LinuxTrayInitializer", "Error initializing StatusIcon: ${e.message}") - dispose() - } - } - - private fun startGtkMainLoopIfInitialized() { - if (isInitialized.get()) { - Gtk.INSTANCE.gtk_main() - } - } - -} diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt new file mode 100644 index 0000000..99c2a03 --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt @@ -0,0 +1,89 @@ +package com.kdroid.composetray.tray.impl + +import com.kdroid.composetray.lib.mac.MacTrayManager +import com.kdroid.composetray.menu.api.TrayMenuBuilder +import com.kdroid.composetray.menu.impl.MacTrayMenuBuilderImpl + +object MacTrayInitializer { + + private var trayMenuImpl: MacTrayMenuBuilderImpl? = null + private var macTrayManager: MacTrayManager? = null + private val lock = Object() + + fun initialize(iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null) { + synchronized(lock) { + if (macTrayManager == null) { + // Create a new instance of MacTrayManager if it doesn't exist + macTrayManager = MacTrayManager(iconPath, tooltip, onLeftClick) + + // Create an instance of MacTrayMenuBuilderImpl and apply the menu content + if (menuContent != null) { + trayMenuImpl = MacTrayMenuBuilderImpl( + iconPath, + tooltip, + onLeftClick, + trayManager = macTrayManager // Pass the tray manager reference + ).apply { + menuContent() + } + val menuItems = trayMenuImpl!!.build() + + // Add each menu item to MacTrayManager + menuItems.forEach { macTrayManager!!.addMenuItem(it) } + } + + // Start the macOS tray (this will create its own thread) + macTrayManager!!.startTray() + } else { + // Update the existing tray manager + update(iconPath, tooltip, onLeftClick, menuContent) + } + } + } + + fun update(iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null) { + synchronized(lock) { + if (macTrayManager == null) { + // If tray manager doesn't exist, initialize it + initialize(iconPath, tooltip, onLeftClick, menuContent) + return + } + + // Create a new menu builder and build the menu items + val newMenuItems = if (menuContent != null) { + val newTrayMenuImpl = MacTrayMenuBuilderImpl( + iconPath, + tooltip, + onLeftClick, + trayManager = macTrayManager + ).apply { + menuContent() + } + + // Store the new menu builder + trayMenuImpl?.dispose() + trayMenuImpl = newTrayMenuImpl + + // Build the menu items + newTrayMenuImpl.build() + } else { + null + } + + // Update the tray manager with new properties and menu items + macTrayManager!!.update(iconPath, tooltip, onLeftClick, newMenuItems) + } + } + + fun dispose() { + synchronized(lock) { + // Stop the tray manager + macTrayManager?.stopTray() + macTrayManager = null + + // Clear menu implementation + trayMenuImpl?.dispose() + trayMenuImpl = null + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt index 411989e..25e191f 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt @@ -6,24 +6,33 @@ import com.kdroid.composetray.menu.impl.WindowsTrayMenuBuilderImpl object WindowsTrayInitializer { - private var trayMenuImpl: WindowsTrayMenuBuilderImpl? = null + private var trayManager: WindowsTrayManager? = null fun initialize(iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null) { - val windowsTrayManager = WindowsTrayManager(iconPath, tooltip, onLeftClick) - // Create an instance of WindowsTrayMenuImpl and apply the menu content - trayMenuImpl = WindowsTrayMenuBuilderImpl(iconPath, tooltip, onLeftClick).apply { + // Create menu items + val trayMenuImpl = WindowsTrayMenuBuilderImpl(iconPath, tooltip, onLeftClick).apply { menuContent?.let { it() } } - val menuItems = trayMenuImpl!!.build() + val menuItems = trayMenuImpl.build() - // Add each menu item to WindowsTrayManager - menuItems.forEach { windowsTrayManager.addMenuItem(it) } + if (trayManager == null) { + // Create new manager + val windowsTrayManager = WindowsTrayManager(iconPath, tooltip, onLeftClick) + trayManager = windowsTrayManager + windowsTrayManager.initialize(menuItems) + } else { + // Update existing manager + trayManager?.update(iconPath, tooltip, onLeftClick, menuItems) + } + } - // Start the Windows tray - windowsTrayManager.startTray() + fun update(iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null) { + // Same as initialize - it will handle both cases + initialize(iconPath, tooltip, onLeftClick, menuContent) } fun dispose() { - trayMenuImpl?.dispose() + trayManager?.stopTray() + trayManager = null } -} +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/utils/DarkModeDetector.kt b/src/commonMain/kotlin/com/kdroid/composetray/utils/DarkModeDetector.kt new file mode 100644 index 0000000..d19820a --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/utils/DarkModeDetector.kt @@ -0,0 +1,46 @@ +package com.kdroid.composetray.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.kdroid.composetray.lib.mac.MacOSMenuBarThemeDetector +import io.github.kdroidfilter.platformtools.LinuxDesktopEnvironment +import io.github.kdroidfilter.platformtools.OperatingSystem.* +import io.github.kdroidfilter.platformtools.darkmodedetector.isSystemInDarkMode +import io.github.kdroidfilter.platformtools.detectLinuxDesktopEnvironment +import io.github.kdroidfilter.platformtools.getOperatingSystem +import java.util.function.Consumer + +@Composable +fun isMenuBarInDarkMode(): Boolean { + return when (getOperatingSystem()) { + MACOS -> isMacOsMenuBarInDarkMode() + WINDOWS -> isSystemInDarkMode() + LINUX -> when (detectLinuxDesktopEnvironment()) { + LinuxDesktopEnvironment.GNOME -> true + LinuxDesktopEnvironment.KDE -> isSystemInDarkMode() + LinuxDesktopEnvironment.XFCE -> true + LinuxDesktopEnvironment.CINNAMON -> true + LinuxDesktopEnvironment.MATE -> true + LinuxDesktopEnvironment.UNKNOWN -> true + null -> isSystemInDarkMode() + } + else -> true + } +} + +@Composable +internal fun isMacOsMenuBarInDarkMode(): Boolean { + val darkModeState = remember { mutableStateOf(MacOSMenuBarThemeDetector.isDark()) } + DisposableEffect(Unit) { + val listener = Consumer { newValue -> + darkModeState.value = newValue + } + MacOSMenuBarThemeDetector.registerListener(listener) + onDispose { + MacOSMenuBarThemeDetector.removeListener(listener) + } + } + return darkModeState.value +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/utils/DebugLn.kt b/src/commonMain/kotlin/com/kdroid/composetray/utils/DebugLn.kt new file mode 100644 index 0000000..f15d1ee --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/utils/DebugLn.kt @@ -0,0 +1,64 @@ +package com.kdroid.composetray.utils + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +var allowComposeNativeTrayLogging: Boolean = false +var composeNativeTrayloggingLevel: ComposeNativeTrayLoggingLevel = ComposeNativeTrayLoggingLevel.VERBOSE + +class ComposeNativeTrayLoggingLevel(val priority: Int) { + companion object { + val VERBOSE = ComposeNativeTrayLoggingLevel(0) + val DEBUG = ComposeNativeTrayLoggingLevel(1) + val INFO = ComposeNativeTrayLoggingLevel(2) + val WARN = ComposeNativeTrayLoggingLevel(3) + val ERROR = ComposeNativeTrayLoggingLevel(4) + } +} + +private const val COLOR_RED = "\u001b[31m" +private const val COLOR_AQUA = "\u001b[36m" +private const val COLOR_LIGHT_GRAY = "\u001b[37m" +private const val COLOR_ORANGE = "\u001b[38;2;255;165;0m" +private const val COLOR_RESET = "\u001b[0m" + +// Formatter pour l'heure +private val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") + +private fun getCurrentTimestamp(): String { + return LocalDateTime.now().format(timeFormatter) +} + +internal fun debugln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayloggingLevel.priority <= ComposeNativeTrayLoggingLevel.DEBUG.priority) { + println("[${getCurrentTimestamp()}] ${message()}") + } +} + +internal fun verboseln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayloggingLevel.priority <= ComposeNativeTrayLoggingLevel.VERBOSE.priority) { + println("[${getCurrentTimestamp()}] ${message()}", COLOR_LIGHT_GRAY) + } +} + +internal fun infoln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayloggingLevel.priority <= ComposeNativeTrayLoggingLevel.INFO.priority) { + println("[${getCurrentTimestamp()}] ${message()}", COLOR_AQUA) + } +} + +internal fun warnln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayloggingLevel.priority <= ComposeNativeTrayLoggingLevel.WARN.priority) { + println("[${getCurrentTimestamp()}] ${message()}", COLOR_ORANGE) + } +} + +internal fun errorln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayloggingLevel.priority <= ComposeNativeTrayLoggingLevel.ERROR.priority) { + println("[${getCurrentTimestamp()}] ${message()}", COLOR_RED) + } +} + +private fun println(message: String, color: String) { + println(color + message + COLOR_RESET) +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/utils/MenuContentHash.kt b/src/commonMain/kotlin/com/kdroid/composetray/utils/MenuContentHash.kt new file mode 100644 index 0000000..7ab8a98 --- /dev/null +++ b/src/commonMain/kotlin/com/kdroid/composetray/utils/MenuContentHash.kt @@ -0,0 +1,80 @@ +package com.kdroid.composetray.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentComposer +import com.kdroid.composetray.menu.api.TrayMenuBuilder +import java.security.MessageDigest + +/** + * Utility class for calculating a hash of menu content to detect changes + */ +object MenuContentHash { + + /** + * Calculates a hash of the menu content by capturing the menu structure + * This function should be called from a @Composable context to track state changes + */ + @Composable + fun calculateMenuHash(menuContent: (TrayMenuBuilder.() -> Unit)?): String { + if (menuContent == null) return "empty" + + // Create a capturing menu builder that records all operations + val capturingBuilder = CapturingMenuBuilder() + + // Execute the menu content to capture the current state + // This will automatically recompose when any @Composable state used inside changes + menuContent.invoke(capturingBuilder) + + // Generate hash from captured operations + return capturingBuilder.generateHash() + } + + /** + * A TrayMenuBuilder implementation that captures all menu operations + * to generate a hash representing the menu structure + */ + private class CapturingMenuBuilder : TrayMenuBuilder { + private val operations = mutableListOf() + + override fun Item(label: String, isEnabled: Boolean, onClick: () -> Unit) { + operations.add("Item:$label:$isEnabled") + } + + override fun CheckableItem( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean + ) { + operations.add("CheckableItem:$label:$checked:$isEnabled") + } + + override fun SubMenu( + label: String, + isEnabled: Boolean, + submenuContent: (TrayMenuBuilder.() -> Unit)? + ) { + operations.add("SubMenu:$label:$isEnabled") + if (submenuContent != null) { + operations.add("SubMenuStart") + submenuContent.invoke(this) + operations.add("SubMenuEnd") + } + } + + override fun Divider() { + operations.add("Divider") + } + + override fun dispose() { + // Not needed for capturing + } + + fun generateHash(): String { + val content = operations.joinToString("|") + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(content.toByteArray()) + return digest.fold("") { str, it -> str + "%02x".format(it) }.take(16) // Use only first 16 chars for performance + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/kdroid/composetray/utils/SingleInstanceManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/utils/SingleInstanceManager.kt index 9195466..1a3c85c 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/utils/SingleInstanceManager.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/utils/SingleInstanceManager.kt @@ -1,8 +1,5 @@ package com.kdroid.composetray.utils -import com.kdroid.kmplog.Log -import com.kdroid.kmplog.d -import com.kdroid.kmplog.e import java.io.File import java.io.RandomAccessFile import java.nio.channels.FileChannel @@ -62,7 +59,7 @@ object SingleInstanceManager { fun isSingleInstance(onRestoreRequest: () -> Unit): Boolean { // If the lock is already acquired by this process, we are the first instance if (fileLock != null) { - Log.d(TAG, "The lock is already held by this process") + debugln { "$TAG: The lock is already held by this process" } return true } val lockFile = createLockFile() @@ -71,7 +68,7 @@ object SingleInstanceManager { fileLock = fileChannel?.tryLock() if (fileLock != null) { // We are the only instance - Log.d(TAG, "Lock acquired, starting to watch for restore requests") + debugln { "$TAG: Lock acquired, starting to watch for restore requests" } // Ensure that watching is started only once if (!isWatching) { isWatching = true @@ -81,21 +78,21 @@ object SingleInstanceManager { releaseLock() lockFile.delete() deleteRestoreRequestFile() - Log.d(TAG, "Shutdown hook executed") + debugln { "$TAG: Shutdown hook executed" } }) true } else { // Another instance is already running sendRestoreRequest() - Log.d(TAG, "Restore request sent to the existing instance") + debugln { "$TAG: Restore request sent to the existing instance" } false } } catch (e: OverlappingFileLockException) { // The lock is already held by this process - Log.d(TAG, "The lock is already held by this process (OverlappingFileLockException)") + debugln { "$TAG: The lock is already held by this process (OverlappingFileLockException)" } return true } catch (e: Exception) { - Log.e(TAG, "Error in isSingleInstance", e) + errorln { "$TAG: Error in isSingleInstance: $e" } false } } @@ -111,7 +108,7 @@ object SingleInstanceManager { try { val watchService = FileSystems.getDefault().newWatchService() configuration.lockFilesDir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE) - Log.d(TAG, "Watching directory: ${configuration.lockFilesDir} for restore requests") + debugln { "$TAG: Watching directory: ${configuration.lockFilesDir} for restore requests" } while (true) { val key = watchService.take() for (event in key.pollEvents()) { @@ -121,7 +118,7 @@ object SingleInstanceManager { } val filename = event.context() as Path if (filename.toString() == configuration.restoreRequestFileName) { - Log.d(TAG, "Restore request file detected") + debugln { "$TAG: Restore request file detected" } onRestoreRequest() // Remove the request file after processing deleteRestoreRequestFile() @@ -133,7 +130,7 @@ object SingleInstanceManager { } } } catch (e: Exception) { - Log.e(TAG, "Error in watchForRestoreRequests", e) + errorln { "$TAG: Error in watchForRestoreRequests: $e" } } }.start() } @@ -142,9 +139,9 @@ object SingleInstanceManager { try { val restoreRequestFilePath = configuration.lockFilesDir.resolve(configuration.restoreRequestFileName) Files.createFile(restoreRequestFilePath) - Log.d(TAG, "Restore request file created: $restoreRequestFilePath") + debugln { "$TAG: Restore request file created: $restoreRequestFilePath" } } catch (e: Exception) { - Log.e(TAG, "Error while sending restore request", e) + errorln { "$TAG: Error while sending restore request: $e" } } } @@ -152,9 +149,9 @@ object SingleInstanceManager { try { val restoreRequestFilePath = configuration.lockFilesDir.resolve(configuration.restoreRequestFileName) Files.deleteIfExists(restoreRequestFilePath) - Log.d(TAG, "Restore request file deleted: $restoreRequestFilePath") + debugln { "$TAG: Restore request file deleted: $restoreRequestFilePath" } } catch (e: Exception) { - Log.e(TAG, "Error while deleting restore request file", e) + errorln { "$TAG: Error while deleting restore request file: $e" } } } @@ -162,9 +159,9 @@ object SingleInstanceManager { try { fileLock?.release() fileChannel?.close() - Log.d(TAG, "Lock released") + debugln { "$TAG: Lock released" } } catch (e: Exception) { - Log.e(TAG, "Error while releasing the lock", e) + errorln { "$TAG: Error while releasing the lock: $e" } } } diff --git a/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt b/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt index 823d8cc..49403e0 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt @@ -3,17 +3,48 @@ package com.kdroid.composetray.utils import com.kdroid.composetray.lib.windows.WindowsNativeTrayLibrary import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition +import com.kdroid.composetray.lib.mac.MacTrayManager import com.sun.jna.Native +import com.sun.jna.ptr.IntByReference +import io.github.kdroidfilter.platformtools.LinuxDesktopEnvironment import io.github.kdroidfilter.platformtools.OperatingSystem +import io.github.kdroidfilter.platformtools.detectLinuxDesktopEnvironment import io.github.kdroidfilter.platformtools.getOperatingSystem import java.awt.Toolkit import java.io.File import java.util.* +import java.util.concurrent.atomic.AtomicReference enum class TrayPosition { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT } +// Data class to store exact tray click coordinates +data class TrayClickPosition( + val x: Int, + val y: Int, + val position: TrayPosition +) + +// Global storage for last click position +internal object TrayClickTracker { + private val lastClickPosition = AtomicReference(null) + + fun updateClickPosition(x: Int, y: Int) { + val screenSize = Toolkit.getDefaultToolkit().screenSize + val position = convertPositionToCorner(x, y, screenSize.width, screenSize.height) + lastClickPosition.set(TrayClickPosition(x, y, position)) + saveTrayClickPosition(x, y, position) + } + + fun setClickPosition(x: Int, y: Int, position: TrayPosition) { + lastClickPosition.set(TrayClickPosition(x, y, position)) + saveTrayClickPosition(x, y, position) + } + + fun getLastClickPosition(): TrayClickPosition? = lastClickPosition.get() +} + internal fun convertPositionToCorner(x: Int, y: Int, width: Int, height: Int): TrayPosition { return when { x < width / 2 && y < height / 2 -> TrayPosition.TOP_LEFT @@ -25,6 +56,8 @@ internal fun convertPositionToCorner(x: Int, y: Int, width: Int, height: Int): T private const val PROPERTIES_FILE = "tray_position.properties" private const val POSITION_KEY = "TrayPosition" +private const val X_KEY = "TrayX" +private const val Y_KEY = "TrayY" internal fun saveTrayPosition(position: TrayPosition) { val properties = Properties() @@ -36,14 +69,44 @@ internal fun saveTrayPosition(position: TrayPosition) { file.outputStream().use { properties.store(it, null) } } +internal fun saveTrayClickPosition(x: Int, y: Int, position: TrayPosition) { + val properties = Properties() + val file = File(PROPERTIES_FILE) + if (file.exists()) { + properties.load(file.inputStream()) + } + properties.setProperty(POSITION_KEY, position.name) + properties.setProperty(X_KEY, x.toString()) + properties.setProperty(Y_KEY, y.toString()) + file.outputStream().use { properties.store(it, null) } +} + +internal fun loadTrayClickPosition(): TrayClickPosition? { + val file = File(PROPERTIES_FILE) + if (!file.exists()) return null + + val properties = Properties() + properties.load(file.inputStream()) + + val positionStr = properties.getProperty(POSITION_KEY) ?: return null + val x = properties.getProperty(X_KEY)?.toIntOrNull() ?: return null + val y = properties.getProperty(Y_KEY)?.toIntOrNull() ?: return null + + return try { + TrayClickPosition(x, y, TrayPosition.valueOf(positionStr)) + } catch (e: IllegalArgumentException) { + null + } +} + internal fun getWindowsTrayPosition(nativeResult: String?): TrayPosition { return when (nativeResult) { - null -> throw IllegalArgumentException("La valeur retournée est nulle") + null -> throw IllegalArgumentException("Returned value is null") "top-left" -> TrayPosition.TOP_LEFT "top-right" -> TrayPosition.TOP_RIGHT "bottom-left" -> TrayPosition.BOTTOM_LEFT "bottom-right" -> TrayPosition.BOTTOM_RIGHT - else -> throw IllegalArgumentException("Valeur inconnue : $nativeResult") + else -> throw IllegalArgumentException("Unknown value: $nativeResult") } } @@ -53,7 +116,13 @@ internal fun getWindowsTrayPosition(nativeResult: String?): TrayPosition { * The method evaluates the operating system in use and retrieves the corresponding tray position. * - On Windows, it uses the platform's native library to determine the tray position. * - On macOS, it defaults to a specific standard position. - * - On Linux, the position can be fetched from an application-specific properties file, if available. + * - On Linux, the position is fetched from click coordinates or properties file. + * If no position data is available, it uses desktop environment-specific defaults: + * - GNOME: TOP_RIGHT + * - KDE: BOTTOM_RIGHT + * - XFCE: TOP_RIGHT + * - CINNAMON: BOTTOM_RIGHT + * - MATE: TOP_RIGHT * - For unknown or unsupported operating systems, a default position is returned. * * @return The computed tray position as a [TrayPosition] enum value. @@ -64,14 +133,46 @@ fun getTrayPosition(): TrayPosition { val trayLib: WindowsNativeTrayLibrary = Native.load("tray", WindowsNativeTrayLibrary::class.java) return getWindowsTrayPosition(trayLib.tray_get_notification_icons_region()) } - OperatingSystem.MACOS -> return TrayPosition.TOP_RIGHT //Todo + OperatingSystem.MACOS -> { + val lib: MacTrayManager.MacTrayLibrary = + Native.load("MacTray", MacTrayManager.MacTrayLibrary::class.java) + return getMacTrayPosition(lib.tray_get_status_item_region()) + } OperatingSystem.LINUX -> { + // First check if we have a recent click position in memory + TrayClickTracker.getLastClickPosition()?.let { + return it.position + } + + // Otherwise, try to load from properties file + loadTrayClickPosition()?.let { + return it.position + } + + // Legacy fallback - just position without coordinates val properties = Properties() val file = File(PROPERTIES_FILE) if (file.exists()) { properties.load(file.inputStream()) val position = properties.getProperty(POSITION_KEY, null) - return TrayPosition.valueOf(position) + if (position != null) { + return try { + TrayPosition.valueOf(position) + } catch (e: IllegalArgumentException) { + TrayPosition.TOP_RIGHT + } + } + } + + // If no position is found, use desktop environment-specific defaults + return when (detectLinuxDesktopEnvironment()) { + LinuxDesktopEnvironment.GNOME -> TrayPosition.TOP_RIGHT + LinuxDesktopEnvironment.KDE -> TrayPosition.BOTTOM_RIGHT + LinuxDesktopEnvironment.XFCE -> TrayPosition.TOP_RIGHT + LinuxDesktopEnvironment.CINNAMON -> TrayPosition.BOTTOM_RIGHT + LinuxDesktopEnvironment.MATE -> TrayPosition.TOP_RIGHT + LinuxDesktopEnvironment.UNKNOWN -> TrayPosition.TOP_RIGHT + null -> TrayPosition.TOP_RIGHT } } OperatingSystem.UNKNOWN -> return TrayPosition.TOP_RIGHT @@ -84,17 +185,75 @@ fun getTrayPosition(): TrayPosition { * Calculates the position of a tray window on the screen based on the current tray position and given window dimensions. * * This method determines the coordinates (x, y) where the tray window should be positioned, ensuring alignment - * with the current system tray's placement (top-left, top-right, bottom-left, bottom-right). - * The screen dimensions are retrieved using the system's screen size, and the provided window width and height are used - * to calculate the appropriate position. + * with the current system tray's placement. For Linux, it uses the exact click coordinates when available. * * @param windowWidth The width of the tray window in pixels. * @param windowHeight The height of the tray window in pixels. * @return The calculated position as a [WindowPosition] object containing the x and y coordinates. */ fun getTrayWindowPosition(windowWidth: Int, windowHeight: Int): WindowPosition { - val trayPosition = getTrayPosition() val screenSize = Toolkit.getDefaultToolkit().screenSize + + // For Windows, try to use exact click coordinates, fallback to native and save like Linux + if (getOperatingSystem() == OperatingSystem.WINDOWS) { + // 1) on tente de récupérer ce qu’on a déjà + val cachedPos = TrayClickTracker.getLastClickPosition() ?: loadTrayClickPosition() + + // 2) on interroge la DLL (asynchrone, peut échouer) + getNotificationAreaXYForWindows() + + // 3) on choisit la meilleure info disponible + val freshPos = TrayClickTracker.getLastClickPosition() + val posToUse = cachedPos ?: freshPos + ?: return fallbackCornerPosition(windowWidth, windowHeight) // aucun point fiable + + return calculateWindowPositionFromClick( + posToUse.x, posToUse.y, posToUse.position, + windowWidth, windowHeight, + screenSize.width, screenSize.height + ) + + } + + if (getOperatingSystem() == OperatingSystem.MACOS) { + // 1) cache éventuel + val cached = TrayClickTracker.getLastClickPosition() ?: loadTrayClickPosition() + + // 2) interroge Cocoa + val (x0, y0) = getStatusItemXYForMac() + if (x0 != 0 || y0 != 0) { + TrayClickTracker.setClickPosition( + x0, y0, + getTrayPosition() // TOP_LEFT ou TOP_RIGHT + ) + } + + // 3) choisit la meilleure info + val pos = TrayClickTracker.getLastClickPosition() ?: cached + if (pos != null) { + return calculateWindowPositionFromClick( + pos.x, pos.y, pos.position, + windowWidth, windowHeight, + screenSize.width, screenSize.height + ) + } + } + + + // For Linux, try to use exact click coordinates + if (getOperatingSystem() == OperatingSystem.LINUX) { + val clickPos = TrayClickTracker.getLastClickPosition() ?: loadTrayClickPosition() + if (clickPos != null) { + return calculateWindowPositionFromClick( + clickPos.x, clickPos.y, clickPos.position, + windowWidth, windowHeight, + screenSize.width, screenSize.height + ) + } + } + + // Fallback to corner-based positioning + val trayPosition = getTrayPosition() return when (trayPosition) { TrayPosition.TOP_LEFT -> WindowPosition(x = 0.dp, y = 0.dp) TrayPosition.TOP_RIGHT -> WindowPosition(x = (screenSize.width - windowWidth).dp, y = 0.dp) @@ -105,3 +264,100 @@ fun getTrayWindowPosition(windowWidth: Int, windowHeight: Int): WindowPosition { ) } } + +/** + * Calculate window position based on exact click coordinates. + * This positions the window centered horizontally on the click point (icon), + * and vertically above or below based on tray position, while ensuring it stays within screen bounds. + */ +private fun calculateWindowPositionFromClick( + clickX: Int, clickY: Int, trayPosition: TrayPosition, + windowWidth: Int, windowHeight: Int, + screenWidth: Int, screenHeight: Int +): WindowPosition { + + // Horizontal: center on the icon + var x = clickX - (windowWidth / 2) + + // Adjust if it goes off screen + if (x < 0) { + x = 0 + } else if (x + windowWidth > screenWidth) { + x = screenWidth - windowWidth + } + + // Vertical: depend on tray position (top or bottom) + var y = if (trayPosition == TrayPosition.TOP_LEFT || trayPosition == TrayPosition.TOP_RIGHT) { + // Tray at top: position window below icon + clickY + } else { + // Tray at bottom: position window above icon + clickY - windowHeight + } + + // Ensure window stays within screen bounds vertically + if (y < 0) { + y = 0 + } else if (y + windowHeight > screenHeight) { + y = screenHeight - windowHeight + } + + return WindowPosition(x = x.dp, y = y.dp) +} + +/** + * Interroge la DLL native pour récupérer le coin (x, y) de la zone de notification Windows. + */ +fun getNotificationAreaXYForWindows(): Pair { + val xRef = IntByReference() + val yRef = IntByReference() + + val trayLib: WindowsNativeTrayLibrary = + Native.load("tray", WindowsNativeTrayLibrary::class.java) + + val precise = trayLib.tray_get_notification_icons_position(xRef, yRef) != 0 + + val x = xRef.value + val y = yRef.value + + // On ne mémorise la coordonnée que si elle est fiable + if (precise) { + val trayPosition = getTrayPosition() // TOP_LEFT, TOP_RIGHT, ... + TrayClickTracker.setClickPosition(x, y, trayPosition) + } + + debugln { + "Notification area : ($x, $y) " + + if (precise) "[exact]" else "[fallback]" + } + return x to y +} + +private fun fallbackCornerPosition(w: Int, h: Int): WindowPosition { + val screen = Toolkit.getDefaultToolkit().screenSize + return when (getTrayPosition()) { + TrayPosition.TOP_LEFT -> WindowPosition(0.dp, 0.dp) + TrayPosition.TOP_RIGHT -> WindowPosition((screen.width - w).dp, 0.dp) + TrayPosition.BOTTOM_LEFT -> WindowPosition(0.dp, (screen.height - h).dp) + TrayPosition.BOTTOM_RIGHT -> WindowPosition( + (screen.width - w).dp, (screen.height - h).dp + ) + } +} + +internal fun getMacTrayPosition(nativeResult: String?): TrayPosition = when (nativeResult) { + "top-left" -> TrayPosition.TOP_LEFT + "top-right" -> TrayPosition.TOP_RIGHT + // on ne verra jamais TOP/BOTTOM_BOTTOM sur mac, mais pour rester cohérent : + else -> TrayPosition.TOP_RIGHT +} + +internal fun getStatusItemXYForMac(): Pair { + val xRef = IntByReference() + val yRef = IntByReference() + val lib: MacTrayManager.MacTrayLibrary = + Native.load("MacTray", MacTrayManager.MacTrayLibrary::class.java) + + val precise = lib.tray_get_status_item_position(xRef, yRef) != 0 + return xRef.value to yRef.value // si !precise, valeurs = (0,0) +} diff --git a/src/commonMain/resources/darwin-aarch64/libMacTray.dylib b/src/commonMain/resources/darwin-aarch64/libMacTray.dylib new file mode 100755 index 0000000..e541db7 Binary files /dev/null and b/src/commonMain/resources/darwin-aarch64/libMacTray.dylib differ diff --git a/src/commonMain/resources/darwin-x86-64/libMacTray.dylib b/src/commonMain/resources/darwin-x86-64/libMacTray.dylib new file mode 100755 index 0000000..31ce4eb Binary files /dev/null and b/src/commonMain/resources/darwin-x86-64/libMacTray.dylib differ diff --git a/src/commonMain/resources/linux-x86-64/libtray.so b/src/commonMain/resources/linux-x86-64/libtray.so new file mode 100755 index 0000000..28c8034 Binary files /dev/null and b/src/commonMain/resources/linux-x86-64/libtray.so differ diff --git a/src/commonMain/resources/win32-arm64/tray.dll b/src/commonMain/resources/win32-arm64/tray.dll index f8a2211..6a1057c 100644 Binary files a/src/commonMain/resources/win32-arm64/tray.dll and b/src/commonMain/resources/win32-arm64/tray.dll differ diff --git a/src/commonMain/resources/win32-x86-64/tray.dll b/src/commonMain/resources/win32-x86-64/tray.dll index e823b4d..9e58d25 100644 Binary files a/src/commonMain/resources/win32-x86-64/tray.dll and b/src/commonMain/resources/win32-x86-64/tray.dll differ diff --git a/winlib b/winlib index 73a00dd..d4b26aa 160000 --- a/winlib +++ b/winlib @@ -1 +1 @@ -Subproject commit 73a00dd6dc2312d4b9d93b001f62c35af22fa99b +Subproject commit d4b26aa6f00941106c5cbd8b8d390511ebff81a8