diff --git a/README.md b/README.md index 88862be..0213d26 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,28 @@ Add the dependency to your `build.gradle.kts`: ```kotlin dependencies { + // System tray icon + menu (lightweight β€” no windowing backend pulled in) implementation("dev.nucleusframework:composenativetray:") + + // Only if you use TrayApp (the tray + anchored popup window API). + // Pulls in the Nucleus application / decorated-window-tao backend. + implementation("dev.nucleusframework:composenativetray-app:") } ``` +> Since `2.1.0` the library is split in two so apps that only need a tray icon don't pull in the +> heavier `decorated-window-tao` windowing backend (see +> [#418](https://github.com/NucleusFramework/ComposeNativeTray/issues/418)). `Tray` and the menu DSL +> live in `composenativetray`; `TrayApp` lives in `composenativetray-app`. Add the second artifact +> only when you use `TrayApp`. + ## πŸš€ Quick Start -Minimal example to create a system tray icon with menu: +Minimal example to create a system tray icon with menu. `Tray` runs inside Nucleus' +`nucleusApplication { … }` (import `dev.nucleusframework.application.nucleusApplication`): ```kotlin -application { +nucleusApplication { Tray( icon = Icons.Default.Favorite, tooltip = "My Application" @@ -126,8 +138,9 @@ application { > > Notable demos: > - DemoWithDrawableResources.kt – shows using DrawableResource directly for Tray and menu icons -> - PainterResourceWorkaroundDemo.kt – demonstrates the painterResource variable workaround +> - DemoWithPainter.kt – demonstrates using a `painterResource` icon > - DemoWithoutContextMenu.kt – minimalist tray with primary action only +> - TrayAppDemo.kt – the full TrayApp (tray + popup window) example ## πŸ“š Usage Guide @@ -307,7 +320,7 @@ See demo/DemoWithDrawableResources.kt for a complete example. When using `painterResource` with menu items, declare it in the composable context: ```kotlin -application { +nucleusApplication { val advancedIcon = painterResource(Res.drawable.advanced) // βœ… Correct Tray(/* config */) { @@ -342,7 +355,7 @@ if (!isWindowVisible) { } // Example 2: Fully reactive menu -application { +nucleusApplication { var darkMode by remember { mutableStateOf(false) } var showAdvancedOptions by remember { mutableStateOf(false) } var notificationsEnabled by remember { mutableStateOf(true) } @@ -411,84 +424,66 @@ The single instance manager combined with the primary action (left-click) is par - Preserving the current state of the application during restoration - Offering behavior similar to native system applications -Implementation example with `SingleInstanceManager` (provided by Nucleus): +Single instance is **enabled by default** in `nucleusApplication`; pass `enableSingleInstance = false` +to opt out. When a second launch is detected, the already-running instance is notified through +`SingleInstanceRestoreEffect` β€” restore your window (or re-open the tray popup) there instead of +starting a new process: ```kotlin -import dev.nucleusframework.core.runtime.SingleInstanceManager +import dev.nucleusframework.application.SingleInstanceRestoreEffect -var isWindowVisible by remember { mutableStateOf(true) } +nucleusApplication(enableSingleInstance = true) { // true is the default + var isWindowVisible by remember { mutableStateOf(true) } -val isSingleInstance = SingleInstanceManager.isSingleInstance( - onRestoreRequest = { - isWindowVisible = true // Restore the existing window + // Runs in the already-running instance each time the app is launched again. + SingleInstanceRestoreEffect { + isWindowVisible = true // bring the existing window / tray popup back } -) -if (!isSingleInstance) { - exitApplication() - return@application + // ... Tray / TrayApp / windows } ``` -#### Passing data to the main instance - -In some cases, you may want to pass some data to the main instance, e.g. pass a deeplink, -that new instance got in the arguments of the `main` function from OS. +See `TrayAppDemo.kt` for a working example (a second launch re-opens the tray popup). -For this purpose you can use optional `onRestoreFileCreated` handler to write required data to the special file, -that will be later accessible to read in the `onRestoreRequest` handler of the main instance. +#### Deep links -Both handlers have the `Path` as a receiver, so you can do any read/write operations on it. +To react to a deep link the OS hands to the app (including one that arrives on a second launch while +single instance is enabled), register a handler on the application scope: ```kotlin -SingleInstanceManager.isSingleInstance( - onRestoreFileCreated = { - args.firstOrNull()?.let(::writeText) - }, - onRestoreRequest = { - log("Restored with arg: '${readText()}'") - // restore window/etc. - } -) +nucleusApplication { + onDeepLink { uri -> + // handle the incoming URI (navigate, restore state, …) + } + + // ... Tray / TrayApp / windows +} ``` -#### Custom Configuration +### πŸ“ Position Detection + +Tray positioning needs screen geometry (the Tao backend), so these helpers live in the +**`composenativetray-app`** artifact (package `dev.nucleusframework.composenativetray.trayapp`) β€” +the same one that provides `TrayApp`, which uses them internally. -For finer control, configure the `SingleInstanceManager`: +`getTrayPosition()` tells you which screen corner the tray icon sits in: ```kotlin -SingleInstanceManager.configuration = Configuration( - lockFilesDir = Paths.get("path/to/your/app/data/dir/single_instance_manager"), - appIdentifier = "app_id" -) +val corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LEFT / BOTTOM_RIGHT ``` -This allows limiting the scope of the single instance to a specific directory or identifying different versions of your application. - -### πŸ“ Position Detection +- **Windows / macOS**: resolved from the native tray/menu-bar region. +- **Linux**: uses the desktop-environment convention (no reliable native tray-region API). -Precisely position your windows relative to the system tray icon: +`getTrayWindowPosition(...)` computes a precise window position anchored to the tray icon: ```kotlin -val windowWidth = 800 -val windowHeight = 600 -val windowPosition = getTrayWindowPosition(windowWidth, windowHeight) - -Window( - state = rememberWindowState( - width = windowWidth.dp, - height = windowHeight.dp, - position = windowPosition - ) -) { /* content */ } +val windowPosition = getTrayWindowPosition(windowWidth = 800, windowHeight = 600) ``` -**Implementation Details:** -- **Windows**: Uses the Windows native API to get the exact position -- **macOS**: Uses the Cocoa API for the position in the menu bar -- **Linux**: Captures coordinates when clicking on the icon - -The window is automatically horizontally centered on the icon and vertically positioned based on whether the system tray is at the top or bottom of the screen. +The window is horizontally centered on the icon and vertically anchored to the top or bottom of the +screen depending on where the tray lives. For a ready-made tray + popup window, prefer `TrayApp`. ### πŸŒ“ Dark Mode Detection @@ -575,6 +570,16 @@ Add the following to your ProGuard rules file: **Works on Windows, macOS, and Linux.** Smooth fade animations, smart positioning near the tray, and a simple API so you stay productive. +> **πŸ“¦ Dependency:** `TrayApp` lives in a separate artifact so basic-tray users don't pull in the +> windowing backend. Add it in addition to the core: +> ```kotlin +> implementation("dev.nucleusframework:composenativetray-app:") +> ``` +> It requires the Nucleus Tao backend β€” launch your app with `nucleusApplication { … }`. `TrayApp`, +> `TrayAppState`, `rememberTrayAppState` and `TrayWindowDismissMode` live in the +> `dev.nucleusframework.composenativetray.trayapp` package. See the `TrayAppDemo` in the `demo` +> module for a complete example. + --- ## Why you’ll like it @@ -591,7 +596,7 @@ Add the following to your ProGuard rules file: ## Quick Start (minimal) ```kotlin -application { +nucleusApplication { val trayAppState = rememberTrayAppState( initialWindowSize = DpSize(300.dp, 420.dp), initiallyVisible = true // default is false @@ -604,7 +609,6 @@ application { tooltip = "My Tray App", // required // Optional visual controls (defaults shown below) - transparent = true, // default = true undecorated = true, // default = true resizable = false, // default = false windowsTitle = "My Tray Popup", // default = "" β€” recommended (esp. on Linux & when undecorated=false) diff --git a/build.gradle.kts b/build.gradle.kts index f9ba356..764470d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -50,7 +50,7 @@ kotlin { implementation(libs.nucleus.core.runtime) implementation(libs.nucleus.darkmode.detector) api(libs.nucleus.application) - implementation(libs.nucleus.decorated.window.tao) + // decorated-window-tao moved to the :tray-app module (TrayApp-only); see issue #418. } jvmTest.dependencies { implementation(kotlin("test")) diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 16f971c..f2d966d 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(project(":")) + implementation(project(":tray-app")) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.desktop.currentOs) diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt index b92066a..83876ec 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt @@ -19,8 +19,8 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition -import dev.nucleusframework.composenativetray.utils.getTrayWindowPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayWindowPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt index 81cff4e..451c4e2 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithContextMenu.kt @@ -19,7 +19,7 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt index 33a7509..b3e47e0 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithImageVector.kt @@ -13,7 +13,7 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt index 37aa40c..896153e 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPainter.kt @@ -11,7 +11,7 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt index d597f07..223dd52 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithPlatformSpecificIcons.kt @@ -13,7 +13,7 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt index 7557c85..4e45fc6 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoWithoutContextMenu.kt @@ -18,7 +18,7 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt index 4809e10..ae0ef30 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DynamicTrayMenu.kt @@ -12,7 +12,7 @@ import dev.nucleusframework.composenativetray.tray.api.Tray import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel -import dev.nucleusframework.composenativetray.utils.getTrayPosition +import dev.nucleusframework.composenativetray.trayapp.getTrayPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar diff --git a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt index 4f76a3d..931dfb7 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/TrayAppDemo.kt @@ -19,9 +19,9 @@ import composenativetray.demo.generated.resources.Res import composenativetray.demo.generated.resources.icon import dev.nucleusframework.application.SingleInstanceRestoreEffect import dev.nucleusframework.application.nucleusApplication -import dev.nucleusframework.composenativetray.tray.api.TrayApp -import dev.nucleusframework.composenativetray.tray.api.TrayWindowDismissMode -import dev.nucleusframework.composenativetray.tray.api.rememberTrayAppState +import dev.nucleusframework.composenativetray.trayapp.TrayApp +import dev.nucleusframework.composenativetray.trayapp.TrayWindowDismissMode +import dev.nucleusframework.composenativetray.trayapp.rememberTrayAppState import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.material.MaterialDecoratedWindow diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9a0bc7..5985462 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,8 +5,8 @@ kotlinx-coroutines = "1.11.0" compose = "1.11.1" detekt = "1.23.8" ktlint = "12.1.2" -nucleus = "2.0.3" -nucleus-plugin = "2.0.3" +nucleus = "2.0.5" +nucleus-plugin = "2.0.5" [libraries] kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5321d95..4b8dac0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,5 @@ dependencyResolutionManagement { } } +include(":tray-app") include(":demo") diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxTrayManager.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxTrayManager.kt index 047cc67..d8b6c56 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxTrayManager.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/linux/LinuxTrayManager.kt @@ -170,7 +170,7 @@ internal class LinuxTrayManager( try { val xy = IntArray(2) native.nativeGetLastClickXY(trayHandle, xy) - TrayClickTracker.updateClickPosition(xy[0], xy[1]) + TrayClickTracker.recordClick(xy[0], xy[1]) } catch (_: Throwable) { } onLeftClick?.invoke() diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt index 4ab0634..6db6ffc 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/lib/windows/WindowsTrayManager.kt @@ -1,8 +1,6 @@ package dev.nucleusframework.composenativetray.lib.windows import dev.nucleusframework.composenativetray.utils.TrayClickTracker -import dev.nucleusframework.composenativetray.utils.TrayScreenGeometry -import dev.nucleusframework.composenativetray.utils.convertPositionToCorner import dev.nucleusframework.composenativetray.utils.debugln import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -254,26 +252,9 @@ internal class WindowsTrayManager( val precise = WindowsNativeBridge.nativeGetNotificationIconsPosition(outXY) != 0 log("nativeGetNotificationIconsPosition: precise=$precise, rawX=${outXY[0]}, rawY=${outXY[1]}") if (precise) { - // Native coordinates are in physical pixels; window positioning - // works in logical pixels. Convert via the primary monitor scale. - val scale = TrayScreenGeometry.scale() - val logicalX = (outXY[0] / scale).toInt() - val logicalY = (outXY[1] / scale).toInt() - - val screen = TrayScreenGeometry.workAreaLogical() - log( - "DPI scale=$scale, logicalX=$logicalX, logicalY=$logicalY, " + - "screenW=${screen.width}, screenH=${screen.height}", - ) - val corner = - convertPositionToCorner( - logicalX - screen.x, - logicalY - screen.y, - screen.width, - screen.height, - ) - log("Detected corner: $corner") - TrayClickTracker.setClickPosition(instanceId, logicalX, logicalY, corner) + // Record the raw physical click; composenativetray-app converts to logical and + // resolves the corner against the Tao-backed screen geometry it owns. + TrayClickTracker.recordClick(instanceId, outXY[0], outXY[1]) true } else { false diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt index 9e24ca3..5b02266 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/NativeTray.kt @@ -39,7 +39,12 @@ import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import kotlin.internal.LowPriorityInOverloadResolution -internal class NativeTray { +/** + * Low-level tray engine shared by the `Tray` composables and by `TrayApp` (in the separate + * `composenativetray-app` module). Public so the tray-app module can drive it directly; most + * callers should use the `Tray` / `TrayApp` composables instead. + */ +class NativeTray { private val trayScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val os = Platform.Current diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt index 64d7201..f45ba55 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.composenativetray.tray.impl +import dev.nucleusframework.composenativetray.lib.mac.MacNativeBridge import dev.nucleusframework.composenativetray.lib.mac.MacTrayManager import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder import dev.nucleusframework.composenativetray.menu.impl.MacTrayMenuBuilderImpl @@ -18,6 +19,35 @@ object MacTrayInitializer { @Synchronized internal fun getNativeTrayHandle(id: String): Long = trayManagers[id]?.getNativeTrayHandle() ?: 0L + // Status-item queries used by the composenativetray-app module to place the TrayApp popup, + // wrapping the internal JNI bridge so it stays out of the public surface. + + /** Global status-item position in physical pixels; `false` if not precisely available. */ + fun statusItemPosition(outXY: IntArray): Boolean = + runCatching { MacNativeBridge.nativeGetStatusItemPosition(outXY) != 0 }.getOrDefault(false) + + /** Global status-item screen region ("top-left" | "top-right" | …), or `null` if unavailable. */ + fun statusItemRegion(): String? = runCatching { MacNativeBridge.nativeGetStatusItemRegion() }.getOrNull() + + /** Per-instance status-item position in physical pixels; `false` if the tray/handle isn't ready. */ + @Synchronized + fun statusItemPositionFor( + id: String, + outXY: IntArray, + ): Boolean { + val handle = getNativeTrayHandle(id) + if (handle == 0L) return false + return runCatching { MacNativeBridge.nativeGetStatusItemPositionFor(handle, outXY) != 0 }.getOrDefault(false) + } + + /** Per-instance status-item screen region ("top-left" | "top-right" | …), or `null` if unavailable. */ + @Synchronized + fun statusItemRegionFor(id: String): String? { + val handle = getNativeTrayHandle(id) + if (handle == 0L) return null + return runCatching { MacNativeBridge.nativeGetStatusItemRegionFor(handle) }.getOrNull() + } + @Synchronized fun initialize( id: String, diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt index 0317dfd..c5ab2cc 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/WindowsTrayInitializer.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.composenativetray.tray.impl +import dev.nucleusframework.composenativetray.lib.windows.WindowsNativeBridge import dev.nucleusframework.composenativetray.lib.windows.WindowsTrayManager import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder import dev.nucleusframework.composenativetray.menu.impl.WindowsTrayMenuBuilderImpl @@ -10,6 +11,14 @@ object WindowsTrayInitializer { // Manage multiple tray managers by ID to allow multiple tray icons private val trayManagers: MutableMap = mutableMapOf() + /** + * Native notification-area region ("top-left" | "top-right" | …), or `null` if unavailable. + * Exposed for the composenativetray-app module's tray-corner detection; wraps the internal + * JNI bridge so it stays out of the public surface. + */ + fun notificationIconsRegion(): String? = + runCatching { WindowsNativeBridge.nativeGetNotificationIconsRegion() }.getOrNull() + @Synchronized fun initialize( id: String, diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayClickTracker.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayClickTracker.kt new file mode 100644 index 0000000..94df9fc --- /dev/null +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayClickTracker.kt @@ -0,0 +1,44 @@ +package dev.nucleusframework.composenativetray.utils + +import java.util.Collections +import java.util.concurrent.atomic.AtomicReference + +/** A tray-icon click position in the native backend's coordinates (physical px on Windows, logical elsewhere). */ +data class TrayClickPoint( + val x: Int, + val y: Int, +) + +/** + * Records the last tray-icon click reported by the native backends. + * + * The core tray itself doesn't need screen geometry; only `TrayApp`'s popup does. So the native + * managers record the raw click coordinates here and the `composenativetray-app` module reads them + * to compute the popup position against the (Tao-backed) screen geometry it owns. + */ +object TrayClickTracker { + private val last = AtomicReference(null) + private val perInstance: MutableMap = + Collections.synchronizedMap(mutableMapOf()) + + fun recordClick( + x: Int, + y: Int, + ) { + last.set(TrayClickPoint(x, y)) + } + + fun recordClick( + instanceId: String, + x: Int, + y: Int, + ) { + val point = TrayClickPoint(x, y) + perInstance[instanceId] = point + last.set(point) + } + + fun lastClick(): TrayClickPoint? = last.get() + + fun lastClick(instanceId: String): TrayClickPoint? = perInstance[instanceId] +} diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt deleted file mode 100644 index dbe343e..0000000 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt +++ /dev/null @@ -1,560 +0,0 @@ -package dev.nucleusframework.composenativetray.utils - -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.WindowPosition -import dev.nucleusframework.composenativetray.lib.mac.MacNativeBridge -import dev.nucleusframework.composenativetray.lib.windows.WindowsNativeBridge -import dev.nucleusframework.composenativetray.tray.impl.MacTrayInitializer -import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment -import dev.nucleusframework.core.runtime.Platform -import java.io.File -import java.util.Collections -import java.util.Properties -import java.util.concurrent.atomic.AtomicReference -import kotlin.math.roundToInt - -enum class TrayPosition { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT } - -data class TrayClickPosition( - val x: Int, - val y: Int, - val position: TrayPosition, -) - -internal object TrayClickTracker { - private val lastClickPosition = AtomicReference(null) - private val perInstancePositions: MutableMap = - Collections.synchronizedMap(mutableMapOf()) - - fun updateClickPosition( - x: Int, - y: Int, - ) { - val bounds = getScreenBoundsAt(x, y) - val position = convertPositionToCorner(x - bounds.x, y - bounds.y, bounds.width, bounds.height) - val pos = TrayClickPosition(x, y, position) - lastClickPosition.set(pos) - runCatching { saveTrayClickPosition(x, y, position) } - } - - fun updateClickPosition( - instanceId: String, - x: Int, - y: Int, - ) { - val bounds = getScreenBoundsAt(x, y) - val position = convertPositionToCorner(x - bounds.x, y - bounds.y, bounds.width, bounds.height) - val pos = TrayClickPosition(x, y, position) - perInstancePositions[instanceId] = pos - lastClickPosition.set(pos) - runCatching { saveTrayClickPosition(x, y, position) } - } - - fun setClickPosition( - x: Int, - y: Int, - position: TrayPosition, - ) { - val pos = TrayClickPosition(x, y, position) - lastClickPosition.set(pos) - runCatching { saveTrayClickPosition(x, y, position) } - } - - fun setClickPosition( - instanceId: String, - x: Int, - y: Int, - position: TrayPosition, - ) { - val pos = TrayClickPosition(x, y, position) - perInstancePositions[instanceId] = pos - lastClickPosition.set(pos) - runCatching { saveTrayClickPosition(x, y, position) } - } - - fun getLastClickPosition(): TrayClickPosition? = lastClickPosition.get() - - fun getLastClickPosition(instanceId: String): TrayClickPosition? = perInstancePositions[instanceId] -} - -/** - * Returns the bounds of the screen containing the given point. The Tao bridge - * only exposes the primary monitor's work area, so multi-monitor setups - * resolve to the primary work area (the tray lives there on all platforms). - */ -@Suppress("UNUSED_PARAMETER") -private fun getScreenBoundsAt( - x: Int, - y: Int, -): ScreenRect = TrayScreenGeometry.workAreaLogical() - -internal fun convertPositionToCorner( - x: Int, - y: Int, - width: Int, - height: Int, -): TrayPosition { - // Use smarter margins based on typical taskbar/panel size - // 100px from edge = probably within taskbar/panel area - val edgeThreshold = 100 - - val isNearTop = y < edgeThreshold - val isNearBottom = y > height - edgeThreshold - val isNearLeft = x < edgeThreshold - val isNearRight = x > width - edgeThreshold - - return when { - // Strong edge detection first - isNearTop && isNearLeft -> TrayPosition.TOP_LEFT - isNearTop && isNearRight -> TrayPosition.TOP_RIGHT - isNearTop -> TrayPosition.TOP_RIGHT // Default top to right - isNearBottom && isNearLeft -> TrayPosition.BOTTOM_LEFT - isNearBottom && isNearRight -> TrayPosition.BOTTOM_RIGHT - isNearBottom -> TrayPosition.BOTTOM_RIGHT // Default bottom to right - // Fallback: use quadrant-based detection - x >= width / 2 && y < height / 2 -> TrayPosition.TOP_RIGHT - x < width / 2 && y < height / 2 -> TrayPosition.TOP_LEFT - x >= width / 2 -> TrayPosition.BOTTOM_RIGHT - else -> TrayPosition.BOTTOM_LEFT - } -} - -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" - -private fun trayPropertiesFile(): File { - val appId = AppIdProvider.appId() - val tmpBase = System.getProperty("java.io.tmpdir") ?: "." - val tmpDir = File(File(tmpBase, "ComposeNativeTray"), appId) - val macCacheDir = macCacheDir()?.resolve(appId) - val candidates = listOfNotNull(tmpDir, macCacheDir) - for (dir in candidates) { - runCatching { if (!dir.exists()) dir.mkdirs() } - if (dir.exists() && dir.canWrite()) return File(dir, PROPERTIES_FILE) - } - return File(tmpDir, PROPERTIES_FILE) -} - -private fun legacyPropertiesFile(): File = File(PROPERTIES_FILE) - -private fun oldTmpPropertiesFile(): File { - val tmpBase = System.getProperty("java.io.tmpdir") ?: "." - val oldDir = File(tmpBase, "ComposeNativeTray") - return File(oldDir, PROPERTIES_FILE) -} - -private fun macCachePropertiesFile(): File? { - val appId = AppIdProvider.appId() - val dir = macCacheDir()?.resolve(appId) ?: return null - return File(dir, PROPERTIES_FILE) -} - -private fun macCacheDir(): File? { - val userHome = System.getProperty("user.home") ?: return null - return File(userHome).resolve("Library").resolve("Caches").resolve("ComposeNativeTray") -} - -private fun loadPropertiesFrom(file: File): Properties? { - if (!file.exists()) return null - return runCatching { - Properties().apply { file.inputStream().use(::load) } - }.getOrNull() -} - -private fun storePropertiesTo( - file: File, - props: Properties, -) { - file.parentFile?.let { runCatching { if (!it.exists()) it.mkdirs() } } - runCatching { file.outputStream().use { props.store(it, null) } } -} - -internal fun saveTrayPosition(position: TrayPosition) { - val preferredFile = trayPropertiesFile() - val properties = - loadPropertiesFrom(preferredFile) - ?: macCachePropertiesFile()?.let { loadPropertiesFrom(it) } - ?: loadPropertiesFrom(oldTmpPropertiesFile()) - ?: loadPropertiesFrom(legacyPropertiesFile()) - ?: Properties() - properties.setProperty(POSITION_KEY, position.name) - storePropertiesTo(preferredFile, properties) -} - -internal fun saveTrayClickPosition( - x: Int, - y: Int, - position: TrayPosition, -) { - val preferredFile = trayPropertiesFile() - val properties = - loadPropertiesFrom(preferredFile) - ?: macCachePropertiesFile()?.let { loadPropertiesFrom(it) } - ?: loadPropertiesFrom(oldTmpPropertiesFile()) - ?: loadPropertiesFrom(legacyPropertiesFile()) - ?: Properties() - properties.setProperty(POSITION_KEY, position.name) - properties.setProperty(X_KEY, x.toString()) - properties.setProperty(Y_KEY, y.toString()) - storePropertiesTo(preferredFile, properties) -} - -internal fun loadTrayClickPosition(): TrayClickPosition? { - val props = - loadPropertiesFrom(trayPropertiesFile()) - ?: macCachePropertiesFile()?.let { loadPropertiesFrom(it) } - ?: loadPropertiesFrom(oldTmpPropertiesFile()) - ?: loadPropertiesFrom(legacyPropertiesFile()) ?: return null - - val positionStr = props.getProperty(POSITION_KEY) ?: return null - val x = props.getProperty(X_KEY)?.toIntOrNull() ?: return null - val y = props.getProperty(Y_KEY)?.toIntOrNull() ?: return null - - return try { - TrayClickPosition(x, y, TrayPosition.valueOf(positionStr)) - } catch (_: IllegalArgumentException) { - null - } -} - -internal fun getWindowsTrayPosition(nativeResult: String?): TrayPosition = - when (nativeResult) { - 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("Unknown value: $nativeResult") - } - -/** OS β†’ Tray corner heuristics */ -fun getTrayPosition(): TrayPosition { - return when (Platform.Current) { - Platform.Windows -> getWindowsTrayPosition(WindowsNativeBridge.nativeGetNotificationIconsRegion()) - Platform.MacOS -> getMacTrayPosition(MacNativeBridge.nativeGetStatusItemRegion()) - Platform.Linux -> { - TrayClickTracker.getLastClickPosition()?.position - ?: loadTrayClickPosition()?.position - ?: run { - val props = - loadPropertiesFrom(trayPropertiesFile()) - ?: macCachePropertiesFile()?.let { loadPropertiesFrom(it) } - ?: loadPropertiesFrom(oldTmpPropertiesFile()) - ?: loadPropertiesFrom(legacyPropertiesFile()) - props?.getProperty(POSITION_KEY)?.let { - runCatching { TrayPosition.valueOf(it) }.getOrNull() - } - } - ?: when (LinuxDesktopEnvironment.Current) { - LinuxDesktopEnvironment.KDE -> TrayPosition.BOTTOM_RIGHT - LinuxDesktopEnvironment.Cinnamon -> TrayPosition.BOTTOM_RIGHT - LinuxDesktopEnvironment.Gnome -> TrayPosition.TOP_RIGHT - LinuxDesktopEnvironment.Mate -> TrayPosition.TOP_RIGHT - LinuxDesktopEnvironment.XFCE -> TrayPosition.TOP_RIGHT - else -> TrayPosition.TOP_RIGHT - } - } - Platform.Unknown -> TrayPosition.TOP_RIGHT - else -> TrayPosition.TOP_RIGHT - } -} - -/** Position globale (sans instance) + offsets */ -fun getTrayWindowPosition( - windowWidth: Int, - windowHeight: Int, - horizontalOffset: Int = 0, - verticalOffset: Int = 0, -): WindowPosition { - val screen = TrayScreenGeometry.workAreaLogical() - - if (Platform.Current == Platform.Windows) { - val freshPos = - TrayClickTracker.getLastClickPosition() - ?: loadTrayClickPosition() - - val posToUse = - freshPos ?: run { - // No click yet (e.g., initiallyVisible = true). Synthesize one near the tray corner - val corner = getTrayPosition() - val (sx, sy) = syntheticClickFromCorner(corner, screen) - return calculateWindowPositionFromClick( - sx, sy, corner, - windowWidth, windowHeight, - horizontalOffset, verticalOffset, - ) - } - - return calculateWindowPositionFromClick( - posToUse.x, - posToUse.y, - posToUse.position, - windowWidth, - windowHeight, - horizontalOffset, - verticalOffset, - ) - } - - if (Platform.Current == Platform.MacOS) { - val (x0, y0) = getStatusItemXYForMac() - if (x0 != 0 || y0 != 0) { - TrayClickTracker.setClickPosition(x0, y0, getTrayPosition()) - } - val pos = TrayClickTracker.getLastClickPosition() - if (pos != null) { - return calculateWindowPositionFromClick( - pos.x, - pos.y, - pos.position, - windowWidth, - windowHeight, - horizontalOffset, - verticalOffset, - ) - } - } - - if (Platform.Current == Platform.Linux) { - val clickPos = TrayClickTracker.getLastClickPosition() ?: loadTrayClickPosition() - if (clickPos != null) { - return calculateWindowPositionFromClick( - clickPos.x, - clickPos.y, - clickPos.position, - windowWidth, - windowHeight, - horizontalOffset, - verticalOffset, - ) - } - } - - return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset) -} - -/** Variante par instance (Windows multi-tray, mac precise) + offsets */ -fun getTrayWindowPositionForInstance( - instanceId: String, - windowWidth: Int, - windowHeight: Int, - horizontalOffset: Int = 0, - verticalOffset: Int = 0, -): WindowPosition { - val os = Platform.Current - - return when (os) { - Platform.Windows -> { - val pos = TrayClickTracker.getLastClickPosition(instanceId) - if (pos == null) { - debugln { - "[TrayPosition] getTrayWindowPositionForInstance: " + - "no position for $instanceId, using fallback" - } - return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset) - } - calculateWindowPositionFromClick( - pos.x, - pos.y, - pos.position, - windowWidth, - windowHeight, - horizontalOffset, - verticalOffset, - ) - } - Platform.MacOS -> { - val trayHandle = MacTrayInitializer.getNativeTrayHandle(instanceId) - if (trayHandle != 0L) { - val outXY = IntArray(2) - val precise = - try { - MacNativeBridge.nativeGetStatusItemPositionFor(trayHandle, outXY) != 0 - } catch (_: Throwable) { - false - } - val x = outXY[0] - val y = outXY[1] - if (precise) { - val regionStr = runCatching { MacNativeBridge.nativeGetStatusItemRegionFor(trayHandle) }.getOrNull() - val trayPos = - if (regionStr != null) { - getMacTrayPosition(regionStr) - } else { - val bounds = getScreenBoundsAt(x, y) - convertPositionToCorner(x - bounds.x, y - bounds.y, bounds.width, bounds.height) - } - TrayClickTracker.setClickPosition(instanceId, x, y, trayPos) - return calculateWindowPositionFromClick( - x, - y, - trayPos, - windowWidth, - windowHeight, - horizontalOffset, - verticalOffset, - ) - } - } - // Tray not registered / status item not realised yet (initialisation - // is async: icon rendering + IO dispatch). Report PlatformDefault so - // callers with a poll loop (visibleOnStart path in TrayAppImplPanel) - // retry until the precise status-item rect is available, instead of - // latching a screen-corner fallback position. - debugln { "[TrayPosition] mac instance $instanceId not ready (handle=$trayHandle), PlatformDefault" } - WindowPosition.PlatformDefault - } - else -> getTrayWindowPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset) - } -} - -/** - * Calcule la position (x,y) depuis un clic prΓ©cis + applique les offsets et un clamp aux bords Γ©cran. - * Coordinates are logical pixels within the primary monitor's work area. - */ -private fun calculateWindowPositionFromClick( - clickX: Int, - clickY: Int, - trayPosition: TrayPosition, - windowWidth: Int, - windowHeight: Int, - horizontalOffset: Int, - verticalOffset: Int, -): WindowPosition { - val os = Platform.Current - val isTop = trayPosition == TrayPosition.TOP_LEFT || trayPosition == TrayPosition.TOP_RIGHT - val isRight = trayPosition == TrayPosition.TOP_RIGHT || trayPosition == TrayPosition.BOTTOM_RIGHT - - val sb = getScreenBoundsAt(clickX, clickY) - debugln { - "[TrayPosition] calculateWindowPositionFromClick: " + - "clickX=$clickX, clickY=$clickY, trayPos=$trayPosition, " + - "winW=$windowWidth, winH=$windowHeight, screenBounds=$sb" - } - - return if (os == Platform.Windows) { - var x = clickX - (windowWidth / 2) - var y = if (isTop) clickY else clickY - windowHeight - debugln { "[TrayPosition] Windows: isTop=$isTop, initial x=$x, y=$y" } - - x += horizontalOffset - y += verticalOffset - - if (x < sb.x) { - x = sb.x - } else if (x + windowWidth > sb.x + sb.width) { - x = sb.x + sb.width - windowWidth - } - if (y < sb.y) { - y = sb.y - } else if (y + windowHeight > sb.y + sb.height) { - y = sb.y + sb.height - windowHeight - } - debugln { "[TrayPosition] Windows: final x=$x, y=$y" } - WindowPosition(x = x.dp, y = y.dp) - } else { - // `sb` is the WORK AREA (Tao bridges): its top edge already sits below - // the macOS menu bar / Linux top panel, and its bottom edge above the - // dock/panel β€” anchor directly at the edge. (The pre-Tao AWT code used - // full-screen bounds and approximated the bar with a 28px guess; keeping - // that guess on top of the work area double-counts the bar.) - var x = clickX - (windowWidth / 2) - val anchorY = if (isTop) sb.y else (sb.y + sb.height) - var y = if (isTop) anchorY else anchorY - windowHeight - - x += if (isRight) -horizontalOffset else horizontalOffset - y += if (isTop) verticalOffset else -verticalOffset - - if (x < sb.x) { - x = sb.x - } else if (x + windowWidth > sb.x + sb.width) { - x = sb.x + sb.width - windowWidth - } - if (y < sb.y) { - y = sb.y - } else if (y + windowHeight > sb.y + sb.height) { - y = sb.y + sb.height - windowHeight - } - WindowPosition(x = x.dp, y = y.dp) - } -} - -/** Position de repli coin + offsets */ -private fun fallbackCornerPosition( - w: Int, - h: Int, - horizontalOffset: Int, - verticalOffset: Int, -): WindowPosition { - val screen = TrayScreenGeometry.workAreaLogical() - return when (getTrayPosition()) { - TrayPosition.TOP_LEFT -> - WindowPosition((screen.x + horizontalOffset).dp, (screen.y + verticalOffset).dp) - TrayPosition.TOP_RIGHT -> - WindowPosition( - (screen.x + screen.width - w + horizontalOffset).dp, - (screen.y + verticalOffset).dp, - ) - TrayPosition.BOTTOM_LEFT -> - WindowPosition( - (screen.x + horizontalOffset).dp, - (screen.y + screen.height - h + verticalOffset).dp, - ) - TrayPosition.BOTTOM_RIGHT -> - WindowPosition( - (screen.x + screen.width - w + horizontalOffset).dp, - (screen.y + screen.height - h + verticalOffset).dp, - ) - } -} - -internal fun getMacTrayPosition(nativeResult: String?): TrayPosition = - when (nativeResult) { - "top-left" -> TrayPosition.TOP_LEFT - "top-right" -> TrayPosition.TOP_RIGHT - else -> TrayPosition.TOP_RIGHT - } - -internal fun getStatusItemXYForMac(): Pair { - val outXY = IntArray(2) - MacNativeBridge.nativeGetStatusItemPosition(outXY) // if not precise, returns (0,0) - return outXY[0] to outXY[1] -} - -fun debugDeleteTrayPropertiesFiles() { - val files = - setOfNotNull( - trayPropertiesFile(), - legacyPropertiesFile(), - oldTmpPropertiesFile(), - macCachePropertiesFile(), - ) - files.filter(File::exists).forEach { runCatching { it.delete() } } -} - -private fun dpiAwareHalfIconOffset(): Int { - val scale = TrayScreenGeometry.scale() - return (15 * scale).roundToInt().coerceAtLeast(0) -} - -private fun syntheticClickFromCorner( - corner: TrayPosition, - screen: ScreenRect, -): Pair { - val half = dpiAwareHalfIconOffset() // ~half icon in px, DPI-aware - val x = - if (corner == TrayPosition.TOP_RIGHT || corner == TrayPosition.BOTTOM_RIGHT) { - screen.x + screen.width - half - } else { - screen.x + half - } - val y = - if (corner == TrayPosition.BOTTOM_LEFT || corner == TrayPosition.BOTTOM_RIGHT) { - screen.y + screen.height - half - } else { - screen.y + half - } - return x to y -} diff --git a/tray-app/build.gradle.kts b/tray-app/build.gradle.kts new file mode 100644 index 0000000..8dde0e7 --- /dev/null +++ b/tray-app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + alias(libs.plugins.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose) + alias(libs.plugins.vannitktech.maven.publish) + alias(libs.plugins.dokka) +} + +group = "dev.nucleusframework" +val ref = System.getenv("GITHUB_REF") ?: "" +val libVersion = + if (ref.startsWith("refs/tags/")) { + val tag = ref.removePrefix("refs/tags/") + if (tag.startsWith("v")) tag.substring(1) else tag + } else { + "dev" + } +version = libVersion + +repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") { + content { + includeGroupByRegex("org\\.jetbrains.*") + } + } +} + +kotlin { + jvmToolchain(17) + jvm() + + sourceSets { + jvmMain.dependencies { + api(project(":")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.nucleus.core.runtime) + // api: NucleusApplicationScope is the receiver type of the public TrayApp API. + api(libs.nucleus.application) + implementation(libs.nucleus.decorated.window.tao) + } + } +} + +mavenPublishing { + coordinates( + groupId = "dev.nucleusframework", + artifactId = "composenativetray-app", + version = libVersion, + ) + + pom { + name.set("Compose Native Tray App") + description.set( + "TrayApp β€” the high-level tray + anchored popup window API for Compose Native Tray. " + + "Depends on the Nucleus application/Tao backend; use the core composenativetray " + + "artifact alone if you only need a basic system tray icon and menu.", + ) + inceptionYear.set("2024") + url.set("https://github.com/NucleusFramework/ComposeNativeTray") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } + + developers { + developer { + id.set("nucleusframework") + name.set("NucleusFramework") + url.set("https://github.com/NucleusFramework") + } + } + + scm { + url.set("https://github.com/NucleusFramework/ComposeNativeTray") + connection.set("scm:git:git://github.com/NucleusFramework/ComposeNativeTray.git") + developerConnection.set("scm:git:ssh://git@github.com/NucleusFramework/ComposeNativeTray.git") + } + } + + publishToMavenCentral() + signAllPublications() +} diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/PersistentAnimatedVisibility.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/PersistentAnimatedVisibility.kt similarity index 99% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/PersistentAnimatedVisibility.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/PersistentAnimatedVisibility.kt index a1ec48b..b5e039d 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/PersistentAnimatedVisibility.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/PersistentAnimatedVisibility.kt @@ -1,4 +1,4 @@ -package dev.nucleusframework.composenativetray.utils +package dev.nucleusframework.composenativetray.trayapp import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt similarity index 98% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt index 0fd7125..8ac46d5 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt @@ -3,7 +3,7 @@ InternalAnimationApi::class, ) -package dev.nucleusframework.composenativetray.tray.api +package dev.nucleusframework.composenativetray.trayapp import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -43,16 +43,11 @@ import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.composenativetray.menu.api.TrayMenuBuilder +import dev.nucleusframework.composenativetray.tray.api.NativeTray import dev.nucleusframework.composenativetray.tray.impl.WindowsTrayInitializer import dev.nucleusframework.composenativetray.utils.ComposableIconUtils import dev.nucleusframework.composenativetray.utils.IconRenderProperties import dev.nucleusframework.composenativetray.utils.MenuContentHash -import dev.nucleusframework.composenativetray.utils.PersistentAnimatedVisibility -import dev.nucleusframework.composenativetray.utils.TrayScreenGeometry -import dev.nucleusframework.composenativetray.utils.debugln -import dev.nucleusframework.composenativetray.utils.errorln -import dev.nucleusframework.composenativetray.utils.getTrayWindowPosition -import dev.nucleusframework.composenativetray.utils.getTrayWindowPositionForInstance import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment import dev.nucleusframework.core.runtime.Platform diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppLogging.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppLogging.kt new file mode 100644 index 0000000..3aa7e1c --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppLogging.kt @@ -0,0 +1,27 @@ +package dev.nucleusframework.composenativetray.trayapp + +import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel +import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging +import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +// Local logging for the tray-app module. Mirrors the core gate (which stays module-private) +// off the shared, public `composeNativeTrayLoggingLevel` / `allowComposeNativeTrayLogging` config, +// and matches core's timestamp format so interleaved stdout lines stay uniform. + +private val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") + +private fun timestamp(): String = LocalDateTime.now().format(timeFormatter) + +internal fun debugln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayLoggingLevel <= ComposeNativeTrayLoggingLevel.DEBUG) { + println("[${timestamp()}] ${message()}") + } +} + +internal fun errorln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayLoggingLevel <= ComposeNativeTrayLoggingLevel.ERROR) { + println("[${timestamp()}] ${message()}") + } +} diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppState.kt similarity index 98% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppState.kt index bcb698b..1d0280a 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppState.kt @@ -1,4 +1,4 @@ -package dev.nucleusframework.composenativetray.tray.api +package dev.nucleusframework.composenativetray.trayapp import androidx.compose.runtime.Composable import androidx.compose.runtime.remember diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayPosition.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayPosition.kt new file mode 100644 index 0000000..27a8a93 --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayPosition.kt @@ -0,0 +1,48 @@ +package dev.nucleusframework.composenativetray.trayapp + +import dev.nucleusframework.composenativetray.tray.impl.MacTrayInitializer +import dev.nucleusframework.composenativetray.tray.impl.WindowsTrayInitializer +import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment +import dev.nucleusframework.core.runtime.Platform + +enum class TrayPosition { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT } + +internal fun getWindowsTrayPosition(nativeResult: String?): TrayPosition = + when (nativeResult) { + 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("Unknown value: $nativeResult") + } + +internal fun getMacTrayPosition(nativeResult: String?): TrayPosition = + when (nativeResult) { + "top-left" -> TrayPosition.TOP_LEFT + "top-right" -> TrayPosition.TOP_RIGHT + else -> TrayPosition.TOP_RIGHT + } + +/** + * OS β†’ tray corner heuristic. + * + * Windows/macOS resolve the corner from the native tray region; Linux uses the desktop-environment + * convention. Lives in the composenativetray-app module alongside the popup positioning it feeds. + */ +fun getTrayPosition(): TrayPosition = + when (Platform.Current) { + Platform.Windows -> getWindowsTrayPosition(WindowsTrayInitializer.notificationIconsRegion()) + Platform.MacOS -> getMacTrayPosition(MacTrayInitializer.statusItemRegion()) + Platform.Linux -> + when (LinuxDesktopEnvironment.Current) { + LinuxDesktopEnvironment.KDE -> TrayPosition.BOTTOM_RIGHT + LinuxDesktopEnvironment.Cinnamon -> TrayPosition.BOTTOM_RIGHT + LinuxDesktopEnvironment.Gnome -> TrayPosition.TOP_RIGHT + LinuxDesktopEnvironment.Mate -> TrayPosition.TOP_RIGHT + LinuxDesktopEnvironment.XFCE -> TrayPosition.TOP_RIGHT + else -> TrayPosition.TOP_RIGHT + } + Platform.Unknown -> TrayPosition.TOP_RIGHT + else -> TrayPosition.TOP_RIGHT + } diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayScreenGeometry.kt similarity index 69% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayScreenGeometry.kt index 5fa96f2..e67333a 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayScreenGeometry.kt @@ -1,4 +1,4 @@ -package dev.nucleusframework.composenativetray.utils +package dev.nucleusframework.composenativetray.trayapp import dev.nucleusframework.window.tao.TaoScreenGeometry import dev.nucleusframework.window.tao.TaoWindow @@ -11,11 +11,12 @@ internal data class ScreenRect( ) /** - * Screen geometry backed by the Tao windowing backend (no AWT). + * Screen geometry backed by the Tao windowing backend. Lives in the tray-app module: it is only + * needed to place `TrayApp`'s popup window, and the Tao backend is always present here β€” so there + * is no "no backend" fallback, the geometry is always real. * - * On Linux the underlying GDK queries need a realized window: `TrayApp` - * registers its popup window through [taoWindowProvider]. Windows/macOS - * resolve the primary monitor directly. + * On Linux the underlying GDK queries need a realized window: `TrayApp` registers its popup window + * through [taoWindowProvider]. Windows/macOS resolve the primary monitor directly. */ internal object TrayScreenGeometry { @Volatile @@ -30,9 +31,9 @@ internal object TrayScreenGeometry { .coerceAtLeast(1f) /** - * Primary monitor work area (screen minus taskbar/menu bar/panel) in - * logical pixels, top-left origin. Falls back to a common resolution when - * the native bridge is unavailable so positioning degrades gracefully. + * Primary monitor work area (screen minus taskbar/menu bar/panel) in logical pixels, top-left + * origin. Only the rare case where the native bridge itself throws degrades to a common + * resolution. */ fun workAreaLogical(): ScreenRect { val wa = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx(taoWindow()) }.getOrNull() diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowDismissMode.kt similarity index 89% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowDismissMode.kt index ede86bb..3de8a57 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowDismissMode.kt @@ -1,4 +1,4 @@ -package dev.nucleusframework.composenativetray.tray.api +package dev.nucleusframework.composenativetray.trayapp /** * Defines how the tray window should be dismissed (hidden) diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt new file mode 100644 index 0000000..111f407 --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt @@ -0,0 +1,368 @@ +package dev.nucleusframework.composenativetray.trayapp + +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import dev.nucleusframework.composenativetray.tray.impl.MacTrayInitializer +import dev.nucleusframework.composenativetray.utils.AppIdProvider +import dev.nucleusframework.composenativetray.utils.TrayClickPoint +import dev.nucleusframework.composenativetray.utils.TrayClickTracker +import dev.nucleusframework.core.runtime.Platform +import java.io.File +import java.util.Properties +import kotlin.math.roundToInt + +// Popup positioning for TrayApp. Lives in the tray-app module because it needs screen geometry +// (Tao) which the core artifact deliberately doesn't depend on. The native tray managers only +// record the raw click coordinate in TrayClickTracker; everything below turns that into a window +// position against the real Tao-backed screen geometry. + +private data class ResolvedClick( + val x: Int, + val y: Int, + val corner: TrayPosition, +) + +/** Windows records the click in physical pixels; convert to logical and derive the corner. */ +private fun resolveWindowsClick(raw: TrayClickPoint): ResolvedClick { + val scale = TrayScreenGeometry.scale() + val logicalX = (raw.x / scale).toInt() + val logicalY = (raw.y / scale).toInt() + val screen = TrayScreenGeometry.workAreaLogical() + val corner = convertPositionToCorner(logicalX - screen.x, logicalY - screen.y, screen.width, screen.height) + return ResolvedClick(logicalX, logicalY, corner) +} + +/** Linux/macOS record the click in logical pixels already; derive the corner. */ +private fun resolveLogicalClick(raw: TrayClickPoint): ResolvedClick { + val screen = TrayScreenGeometry.workAreaLogical() + val corner = convertPositionToCorner(raw.x - screen.x, raw.y - screen.y, screen.width, screen.height) + return ResolvedClick(raw.x, raw.y, corner) +} + +/** Global (instance-less) tray-anchored window position + offsets. */ +fun getTrayWindowPosition( + windowWidth: Int, + windowHeight: Int, + horizontalOffset: Int = 0, + verticalOffset: Int = 0, +): WindowPosition { + val os = Platform.Current + + if (os == Platform.Windows) { + val resolved = + TrayClickTracker.lastClick()?.let { resolveWindowsClick(it) }?.also { savePersistedClick(it) } + ?: loadPersistedClick() + if (resolved == null) { + val corner = getTrayPosition() + val screen = TrayScreenGeometry.workAreaLogical() + val (sx, sy) = syntheticClickFromCorner(corner, screen) + return calculateWindowPositionFromClick( + sx, + sy, + corner, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } + return calculateWindowPositionFromClick( + resolved.x, + resolved.y, + resolved.corner, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } + + if (os == Platform.MacOS) { + val outXY = IntArray(2) + if (MacTrayInitializer.statusItemPosition(outXY)) { + val corner = getTrayPosition() + return calculateWindowPositionFromClick( + outXY[0], + outXY[1], + corner, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } + } + + if (os == Platform.Linux) { + val resolved = + TrayClickTracker.lastClick()?.let { resolveLogicalClick(it) }?.also { savePersistedClick(it) } + ?: loadPersistedClick() + if (resolved != null) { + return calculateWindowPositionFromClick( + resolved.x, + resolved.y, + resolved.corner, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } + } + + return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset) +} + +/** Per-instance variant (Windows multi-tray, macOS precise status-item) + offsets. */ +fun getTrayWindowPositionForInstance( + instanceId: String, + windowWidth: Int, + windowHeight: Int, + horizontalOffset: Int = 0, + verticalOffset: Int = 0, +): WindowPosition { + return when (Platform.Current) { + Platform.Windows -> { + val raw = TrayClickTracker.lastClick(instanceId) + if (raw == null) { + debugln { + "[TrayPosition] getTrayWindowPositionForInstance: no position for $instanceId, using fallback" + } + return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset) + } + val resolved = resolveWindowsClick(raw) + calculateWindowPositionFromClick( + resolved.x, + resolved.y, + resolved.corner, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } + Platform.MacOS -> { + val outXY = IntArray(2) + if (MacTrayInitializer.statusItemPositionFor(instanceId, outXY)) { + val x = outXY[0] + val y = outXY[1] + val regionStr = MacTrayInitializer.statusItemRegionFor(instanceId) + val trayPos = + if (regionStr != null) { + getMacTrayPosition(regionStr) + } else { + val bounds = getScreenBoundsAt(x, y) + convertPositionToCorner(x - bounds.x, y - bounds.y, bounds.width, bounds.height) + } + return calculateWindowPositionFromClick( + x, + y, + trayPos, + windowWidth, + windowHeight, + horizontalOffset, + verticalOffset, + ) + } + // Status item not realised yet (async init). Report PlatformDefault so pollers retry + // rather than latching a corner fallback. + debugln { "[TrayPosition] mac instance $instanceId not ready, PlatformDefault" } + WindowPosition.PlatformDefault + } + else -> getTrayWindowPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset) + } +} + +internal fun convertPositionToCorner( + x: Int, + y: Int, + width: Int, + height: Int, +): TrayPosition { + val edgeThreshold = 100 + + val isNearTop = y < edgeThreshold + val isNearBottom = y > height - edgeThreshold + val isNearLeft = x < edgeThreshold + val isNearRight = x > width - edgeThreshold + + return when { + isNearTop && isNearLeft -> TrayPosition.TOP_LEFT + isNearTop && isNearRight -> TrayPosition.TOP_RIGHT + isNearTop -> TrayPosition.TOP_RIGHT + isNearBottom && isNearLeft -> TrayPosition.BOTTOM_LEFT + isNearBottom && isNearRight -> TrayPosition.BOTTOM_RIGHT + isNearBottom -> TrayPosition.BOTTOM_RIGHT + x >= width / 2 && y < height / 2 -> TrayPosition.TOP_RIGHT + x < width / 2 && y < height / 2 -> TrayPosition.TOP_LEFT + x >= width / 2 -> TrayPosition.BOTTOM_RIGHT + else -> TrayPosition.BOTTOM_LEFT + } +} + +@Suppress("UNUSED_PARAMETER") +private fun getScreenBoundsAt( + x: Int, + y: Int, +): ScreenRect = TrayScreenGeometry.workAreaLogical() + +/** + * Computes the (x,y) window origin from a precise click, applies offsets and clamps to screen edges. + * Coordinates are logical pixels within the primary monitor's work area. + */ +private fun calculateWindowPositionFromClick( + clickX: Int, + clickY: Int, + trayPosition: TrayPosition, + windowWidth: Int, + windowHeight: Int, + horizontalOffset: Int, + verticalOffset: Int, +): WindowPosition { + val os = Platform.Current + val isTop = trayPosition == TrayPosition.TOP_LEFT || trayPosition == TrayPosition.TOP_RIGHT + val isRight = trayPosition == TrayPosition.TOP_RIGHT || trayPosition == TrayPosition.BOTTOM_RIGHT + + val sb = getScreenBoundsAt(clickX, clickY) + + return if (os == Platform.Windows) { + var x = clickX - (windowWidth / 2) + var y = if (isTop) clickY else clickY - windowHeight + + x += horizontalOffset + y += verticalOffset + + if (x < sb.x) { + x = sb.x + } else if (x + windowWidth > sb.x + sb.width) { + x = sb.x + sb.width - windowWidth + } + if (y < sb.y) { + y = sb.y + } else if (y + windowHeight > sb.y + sb.height) { + y = sb.y + sb.height - windowHeight + } + WindowPosition(x = x.dp, y = y.dp) + } else { + var x = clickX - (windowWidth / 2) + val anchorY = if (isTop) sb.y else (sb.y + sb.height) + var y = if (isTop) anchorY else anchorY - windowHeight + + x += if (isRight) -horizontalOffset else horizontalOffset + y += if (isTop) verticalOffset else -verticalOffset + + if (x < sb.x) { + x = sb.x + } else if (x + windowWidth > sb.x + sb.width) { + x = sb.x + sb.width - windowWidth + } + if (y < sb.y) { + y = sb.y + } else if (y + windowHeight > sb.y + sb.height) { + y = sb.y + sb.height - windowHeight + } + WindowPosition(x = x.dp, y = y.dp) + } +} + +private fun fallbackCornerPosition( + w: Int, + h: Int, + horizontalOffset: Int, + verticalOffset: Int, +): WindowPosition { + val screen = TrayScreenGeometry.workAreaLogical() + return when (getTrayPosition()) { + TrayPosition.TOP_LEFT -> + WindowPosition((screen.x + horizontalOffset).dp, (screen.y + verticalOffset).dp) + TrayPosition.TOP_RIGHT -> + WindowPosition( + (screen.x + screen.width - w + horizontalOffset).dp, + (screen.y + verticalOffset).dp, + ) + TrayPosition.BOTTOM_LEFT -> + WindowPosition( + (screen.x + horizontalOffset).dp, + (screen.y + screen.height - h + verticalOffset).dp, + ) + TrayPosition.BOTTOM_RIGHT -> + WindowPosition( + (screen.x + screen.width - w + horizontalOffset).dp, + (screen.y + screen.height - h + verticalOffset).dp, + ) + } +} + +private fun dpiAwareHalfIconOffset(): Int { + val scale = TrayScreenGeometry.scale() + return (15 * scale).roundToInt().coerceAtLeast(0) +} + +private fun syntheticClickFromCorner( + corner: TrayPosition, + screen: ScreenRect, +): Pair { + val half = dpiAwareHalfIconOffset() + val x = + if (corner == TrayPosition.TOP_RIGHT || corner == TrayPosition.BOTTOM_RIGHT) { + screen.x + screen.width - half + } else { + screen.x + half + } + val y = + if (corner == TrayPosition.BOTTOM_LEFT || corner == TrayPosition.BOTTOM_RIGHT) { + screen.y + screen.height - half + } else { + screen.y + half + } + return x to y +} + +// ── Persistence (restore the popup position across launches) ────────────────────── + +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" + +private fun trayPropertiesFile(): File { + val appId = AppIdProvider.appId() + val tmpBase = System.getProperty("java.io.tmpdir") ?: "." + val tmpDir = File(File(tmpBase, "ComposeNativeTray"), appId) + val macCacheDir = macCacheDir()?.resolve(appId) + val candidates = listOfNotNull(tmpDir, macCacheDir) + for (dir in candidates) { + runCatching { if (!dir.exists()) dir.mkdirs() } + if (dir.exists() && dir.canWrite()) return File(dir, PROPERTIES_FILE) + } + return File(tmpDir, PROPERTIES_FILE) +} + +private fun macCacheDir(): File? { + val userHome = System.getProperty("user.home") ?: return null + return File(userHome).resolve("Library").resolve("Caches").resolve("ComposeNativeTray") +} + +private fun loadPersistedClick(): ResolvedClick? { + val file = trayPropertiesFile() + if (!file.exists()) return null + val props = runCatching { Properties().apply { file.inputStream().use(::load) } }.getOrNull() ?: return null + val x = props.getProperty(X_KEY)?.toIntOrNull() ?: return null + val y = props.getProperty(Y_KEY)?.toIntOrNull() ?: return null + val corner = + props.getProperty(POSITION_KEY)?.let { runCatching { TrayPosition.valueOf(it) }.getOrNull() } ?: return null + return ResolvedClick(x, y, corner) +} + +private fun savePersistedClick(resolved: ResolvedClick) { + val file = trayPropertiesFile() + val props = + runCatching { + Properties().apply { if (file.exists()) file.inputStream().use(::load) } + }.getOrElse { Properties() } + props.setProperty(POSITION_KEY, resolved.corner.name) + props.setProperty(X_KEY, resolved.x.toString()) + props.setProperty(Y_KEY, resolved.y.toString()) + file.parentFile?.let { runCatching { if (!it.exists()) it.mkdirs() } } + runCatching { file.outputStream().use { props.store(it, null) } } +}