From c212612630d6ed0b75c0d434260884046846ad09 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 14 Jul 2026 11:54:35 +0300 Subject: [PATCH 1/3] feat: split TrayApp into a separate composenativetray-app module (#418) Move TrayApp and its state/dismiss-mode/animation helpers into a new :tray-app module, published as composenativetray-app, so apps that only need a basic tray icon no longer pull in the decorated-window-tao windowing backend. - Core (composenativetray) keeps the Tray/menu API and native tray impl unchanged, and drops its direct decorated-window-tao dependency. - TrayScreenGeometry is now windowing-agnostic (injectable scale/work-area providers with fallbacks); tray-app installs the Tao-backed geometry. - NativeTray and TrayScreenGeometry are now public (shared engine used by both modules); the public Tray / TrayApp API is unchanged. - Bump Nucleus to 2.0.5. - README: document the two artifacts and correct the snippets to the current nucleusApplication / SingleInstanceRestoreEffect API. --- README.md | 93 ++++++++++--------- build.gradle.kts | 2 +- demo/build.gradle.kts | 1 + gradle/libs.versions.toml | 4 +- settings.gradle.kts | 1 + .../composenativetray/tray/api/NativeTray.kt | 7 +- .../utils/TrayScreenGeometry.kt | 44 +++++---- tray-app/build.gradle.kts | 90 ++++++++++++++++++ .../tray/api}/PersistentAnimatedVisibility.kt | 2 +- .../composenativetray/tray/api/TrayApp.kt | 30 +++++- .../tray/api/TrayAppLogging.kt | 20 ++++ .../tray/api/TrayAppState.kt | 0 .../tray/api/TrayWindowDismissMode.kt | 0 13 files changed, 221 insertions(+), 73 deletions(-) create mode 100644 tray-app/build.gradle.kts rename {src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils => tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api}/PersistentAnimatedVisibility.kt (99%) rename {src => tray-app/src}/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt (96%) create mode 100644 tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt rename {src => tray-app/src}/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt (100%) rename {src => tray-app/src}/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt (100%) diff --git a/README.md b/README.md index 88862be..ebffa78 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,60 +424,43 @@ 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. - } -) -``` - -#### Custom Configuration - -For finer control, configure the `SingleInstanceManager`: +nucleusApplication { + onDeepLink { uri -> + // handle the incoming URI (navigate, restore state, …) + } -```kotlin -SingleInstanceManager.configuration = Configuration( - lockFilesDir = Paths.get("path/to/your/app/data/dir/single_instance_manager"), - appIdentifier = "app_id" -) + // ... Tray / TrayApp / windows +} ``` -This allows limiting the scope of the single instance to a specific directory or identifying different versions of your application. - ### πŸ“ Position Detection Precisely position your windows relative to the system tray icon: @@ -575,6 +571,14 @@ 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 { … }`. See the +> `TrayAppDemo` in the `demo` module for a complete example. + --- ## Why you’ll like it @@ -591,7 +595,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 +608,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/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/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/utils/TrayScreenGeometry.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt index 5fa96f2..06c8081 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt @@ -1,8 +1,5 @@ package dev.nucleusframework.composenativetray.utils -import dev.nucleusframework.window.tao.TaoScreenGeometry -import dev.nucleusframework.window.tao.TaoWindow - internal data class ScreenRect( val x: Int, val y: Int, @@ -11,31 +8,42 @@ internal data class ScreenRect( ) /** - * Screen geometry backed by the Tao windowing backend (no AWT). + * Screen geometry used to place `TrayApp`'s popup window and to detect which screen corner the + * tray icon was clicked in. + * + * The native query is backed by the Tao windowing backend, which lives in the separate + * `composenativetray-app` module β€” that module injects [scaleProvider] / [workAreaPxProvider]. + * When no provider is set (core used on its own, without `TrayApp`) the values fall back to sane + * defaults so corner detection degrades gracefully instead of pulling a windowing dependency into + * the core artifact. * - * On Linux the underlying GDK queries need a realized window: `TrayApp` - * registers its popup window through [taoWindowProvider]. Windows/macOS - * resolve the primary monitor directly. + * Public only so the tray-app module can install the providers; not part of the stable API. */ -internal object TrayScreenGeometry { +object TrayScreenGeometry { + /** Primary monitor scale factor provider (installed by the tray-app module). */ @Volatile - var taoWindowProvider: (() -> TaoWindow?)? = null + var scaleProvider: (() -> Float)? = null - private fun taoWindow(): TaoWindow? = taoWindowProvider?.invoke() + /** + * Primary monitor work-area provider in **physical** pixels as `[x, y, width, height]` + * (installed by the tray-app module). Returns `null` when unavailable. + */ + @Volatile + var workAreaPxProvider: (() -> LongArray?)? = null - /** Primary monitor scale factor (1.0 on non-HiDPI displays). */ - fun scale(): Float = - runCatching { TaoScreenGeometry.primaryMonitorScaleFactor(taoWindow()) } + /** Primary monitor scale factor (1.0 on non-HiDPI displays or when no provider is set). */ + internal fun scale(): Float = + runCatching { scaleProvider?.invoke() ?: 1f } .getOrDefault(1f) .coerceAtLeast(1f) /** - * Primary monitor work area (screen minus taskbar/menu bar/panel) in - * logical pixels, top-left origin. Falls back to a common resolution when - * the native bridge is unavailable so positioning degrades gracefully. + * Primary monitor work area (screen minus taskbar/menu bar/panel) in logical pixels, top-left + * origin. Falls back to a common resolution when no provider is set or the query fails, so + * positioning degrades gracefully. */ - fun workAreaLogical(): ScreenRect { - val wa = runCatching { TaoScreenGeometry.primaryMonitorWorkAreaPx(taoWindow()) }.getOrNull() + internal fun workAreaLogical(): ScreenRect { + val wa = runCatching { workAreaPxProvider?.invoke() }.getOrNull() if (wa == null || wa.size != 4) return ScreenRect(0, 0, FALLBACK_WIDTH, FALLBACK_HEIGHT) val s = scale() return ScreenRect( diff --git a/tray-app/build.gradle.kts b/tray-app/build.gradle.kts new file mode 100644 index 0000000..042af23 --- /dev/null +++ b/tray-app/build.gradle.kts @@ -0,0 +1,90 @@ +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) + implementation(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/tray/api/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/tray/api/PersistentAnimatedVisibility.kt index a1ec48b..c1e3ffd 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/PersistentAnimatedVisibility.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/PersistentAnimatedVisibility.kt @@ -1,4 +1,4 @@ -package dev.nucleusframework.composenativetray.utils +package dev.nucleusframework.composenativetray.tray.api 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/tray/api/TrayApp.kt similarity index 96% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt index 0fd7125..67506f7 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt @@ -47,16 +47,15 @@ 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 +import dev.nucleusframework.window.tao.TaoScreenGeometry import dev.nucleusframework.window.tao.TaoStandalonePopup +import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.isTaoStandalonePopupAvailable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -66,6 +65,23 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +// --------------------- Tao-backed screen geometry --------------------- + +// The core module's `TrayScreenGeometry` (used by the positioning code and the native tray +// managers) is windowing-backend agnostic; the tray-app module installs the actual Tao-backed +// providers here so the `decorated-window-tao` dependency stays out of the core artifact. +// +// On Linux the GDK work-area query needs a realized window; the opaque-window impl publishes the +// popup's TaoWindow through this holder. Windows/macOS resolve the primary monitor from a null +// window, exactly as before the split. +private val taoGeometryWindow = AtomicReference(null) + +private fun installTaoGeometryProviders() { + TrayScreenGeometry.scaleProvider = { TaoScreenGeometry.primaryMonitorScaleFactor(taoGeometryWindow.get()) } + TrayScreenGeometry.workAreaPxProvider = { TaoScreenGeometry.primaryMonitorWorkAreaPx(taoGeometryWindow.get()) } +} // --------------------- Public API (defaults) --------------------- @@ -449,6 +465,10 @@ fun NucleusApplicationScope.TrayApp( return } + // Back the core's windowing-agnostic geometry with the Tao bridge (before any impl composes, + // so the initial window position query resolves against real screen bounds). + remember { installTaoGeometryProviders() } + // Linux gates on runtime availability: the panel is a raw X11 window, // reachable through XWayland on Wayland sessions but absent on rare // Wayland-only setups β€” those fall back to the opaque window impl. @@ -889,8 +909,8 @@ private fun NucleusApplicationScope.TrayAppImplWindow( // Give the position code access to the popup's TaoWindow (needed by // the GDK-backed work-area queries on Linux). DisposableEffect(window) { - TrayScreenGeometry.taoWindowProvider = { window.unsafe.taoWindow } - onDispose { TrayScreenGeometry.taoWindowProvider = null } + taoGeometryWindow.set(window.unsafe.taoWindow) + onDispose { taoGeometryWindow.set(null) } } // On show: raise + focus so focus-loss dismissal works. diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt new file mode 100644 index 0000000..ce7685a --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt @@ -0,0 +1,20 @@ +package dev.nucleusframework.composenativetray.tray.api + +import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel +import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging +import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel + +// Local logging for the tray-app module. Mirrors the core gate (which stays module-private) +// off the shared, public `composeNativeTrayLoggingLevel` / `allowComposeNativeTrayLogging` config. + +internal fun debugln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayLoggingLevel <= ComposeNativeTrayLoggingLevel.DEBUG) { + println("[${java.time.LocalDateTime.now()}] ${message()}") + } +} + +internal fun errorln(message: () -> String) { + if (allowComposeNativeTrayLogging && composeNativeTrayLoggingLevel <= ComposeNativeTrayLoggingLevel.ERROR) { + println("[${java.time.LocalDateTime.now()}] ${message()}") + } +} diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt similarity index 100% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppState.kt diff --git a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt similarity index 100% rename from src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayWindowDismissMode.kt From e16e01d3521cbe9ce2b55c5a6b73e200a12182a7 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 14 Jul 2026 13:34:45 +0300 Subject: [PATCH 2/3] refactor: move geometry/positioning to tray-app, distinct package, no screen fallback Addresses the code-review findings on the module split. - Move TrayScreenGeometry + all popup positioning (getTrayWindowPosition / getTrayWindowPositionForInstance, corner detection, persistence) into :tray-app, where the Tao backend is always present. The core artifact no longer fabricates a 1920x1080 screen when no windowing backend is available: the native tray managers just record the raw tray-icon click, and tray-app resolves the corner + window position against real Tao geometry. macOS native status-item queries are reached through typed MacTrayInitializer wrappers so the JNI bridge stays internal. - Move TrayApp/TrayAppState/TrayWindowDismissMode and friends to package dev.nucleusframework.composenativetray.trayapp so no package is split across the two published JARs (unblocks JPMS module-path consumers). - tray-app: declare nucleus.application as api (it is TrayApp's receiver type). - Align the tray-app log timestamp format with the core formatter. - README: document the trayapp package and that getTrayWindowPosition lives in composenativetray-app; getTrayPosition stays in core. Consequence: getTrayPosition() on Linux, when used without composenativetray-app, returns the desktop-environment default corner instead of a click-derived one. --- README.md | 38 +- .../demo/DemoAdaptivePositionWindows.kt | 2 +- .../composenativetray/demo/TrayAppDemo.kt | 6 +- .../lib/linux/LinuxTrayManager.kt | 2 +- .../lib/windows/WindowsTrayManager.kt | 25 +- .../tray/impl/MacTrayInitializer.kt | 27 + .../composenativetray/utils/TrayPosition.kt | 556 ++---------------- .../utils/TrayScreenGeometry.kt | 59 -- tray-app/build.gradle.kts | 3 +- .../PersistentAnimatedVisibility.kt | 2 +- .../{tray/api => trayapp}/TrayApp.kt | 33 +- .../{tray/api => trayapp}/TrayAppLogging.kt | 15 +- .../{tray/api => trayapp}/TrayAppState.kt | 2 +- .../trayapp/TrayScreenGeometry.kt | 52 ++ .../api => trayapp}/TrayWindowDismissMode.kt | 2 +- .../trayapp/TrayWindowPosition.kt | 377 ++++++++++++ 16 files changed, 547 insertions(+), 654 deletions(-) delete mode 100644 src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt rename tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/{tray/api => trayapp}/PersistentAnimatedVisibility.kt (99%) rename tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/{tray/api => trayapp}/TrayApp.kt (95%) rename tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/{tray/api => trayapp}/TrayAppLogging.kt (59%) rename tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/{tray/api => trayapp}/TrayAppState.kt (98%) create mode 100644 tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayScreenGeometry.kt rename tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/{tray/api => trayapp}/TrayWindowDismissMode.kt (89%) create mode 100644 tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt diff --git a/README.md b/README.md index ebffa78..afad23d 100644 --- a/README.md +++ b/README.md @@ -463,28 +463,26 @@ nucleusApplication { ### πŸ“ Position Detection -Precisely position your windows relative to the system tray icon: +`getTrayPosition()` (in the core artifact) tells you which screen corner the tray icon sits in: ```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 corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LEFT / BOTTOM_RIGHT ``` -**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 +- **Windows / macOS**: resolved from the native tray/menu-bar region. +- **Linux**: uses the desktop-environment convention (no reliable native tray-region API). + +To compute a precise window position anchored to the tray icon, use `getTrayWindowPosition(...)`. Because +it needs screen geometry (the Tao backend), it lives in the **`composenativetray-app`** artifact +(package `dev.nucleusframework.composenativetray.trayapp`) β€” the same one that provides `TrayApp`, +which uses it internally: + +```kotlin +val windowPosition = getTrayWindowPosition(windowWidth = 800, windowHeight = 600) +``` -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 @@ -576,8 +574,10 @@ Add the following to your ProGuard rules file: > ```kotlin > implementation("dev.nucleusframework:composenativetray-app:") > ``` -> It requires the Nucleus Tao backend β€” launch your app with `nucleusApplication { … }`. See the -> `TrayAppDemo` in the `demo` module for a complete example. +> 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. --- 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..2911de1 100644 --- a/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt +++ b/demo/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/demo/DemoAdaptivePositionWindows.kt @@ -20,7 +20,7 @@ import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLeve 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.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/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/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/impl/MacTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt index 64d7201..1219e3e 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,32 @@ 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) + + /** 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/utils/TrayPosition.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt index dbe343e..91d9626 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt @@ -1,222 +1,52 @@ 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( +/** 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, - val position: TrayPosition, ) -internal object TrayClickTracker { - private val lastClickPosition = AtomicReference(null) - private val perInstancePositions: MutableMap = +/** + * 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 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( + fun recordClick( x: Int, y: Int, - position: TrayPosition, ) { - val pos = TrayClickPosition(x, y, position) - lastClickPosition.set(pos) - runCatching { saveTrayClickPosition(x, y, position) } + last.set(TrayClickPoint(x, y)) } - fun setClickPosition( + fun recordClick( 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) + val point = TrayClickPoint(x, y) + perInstance[instanceId] = point + last.set(point) } - 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) } } -} + fun lastClick(): TrayClickPoint? = last.get() -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 - } + fun lastClick(instanceId: String): TrayClickPoint? = perInstance[instanceId] } internal fun getWindowsTrayPosition(nativeResult: String?): TrayPosition = @@ -229,287 +59,6 @@ internal fun getWindowsTrayPosition(nativeResult: String?): TrayPosition = 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 @@ -517,44 +66,27 @@ internal fun getMacTrayPosition(nativeResult: String?): TrayPosition = 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 -} +/** + * OS β†’ tray corner heuristic. + * + * Windows/macOS resolve the corner from the native tray region (no screen geometry needed). Linux + * has no reliable native tray-region API, so it uses the desktop-environment convention. Precise, + * click-derived Linux positioning is provided by `TrayApp` (composenativetray-app), which owns the + * Tao screen geometry. + */ +fun getTrayPosition(): TrayPosition = + when (Platform.Current) { + Platform.Windows -> getWindowsTrayPosition(WindowsNativeBridge.nativeGetNotificationIconsRegion()) + Platform.MacOS -> getMacTrayPosition(MacNativeBridge.nativeGetStatusItemRegion()) + 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/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt deleted file mode 100644 index 06c8081..0000000 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayScreenGeometry.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.nucleusframework.composenativetray.utils - -internal data class ScreenRect( - val x: Int, - val y: Int, - val width: Int, - val height: Int, -) - -/** - * Screen geometry used to place `TrayApp`'s popup window and to detect which screen corner the - * tray icon was clicked in. - * - * The native query is backed by the Tao windowing backend, which lives in the separate - * `composenativetray-app` module β€” that module injects [scaleProvider] / [workAreaPxProvider]. - * When no provider is set (core used on its own, without `TrayApp`) the values fall back to sane - * defaults so corner detection degrades gracefully instead of pulling a windowing dependency into - * the core artifact. - * - * Public only so the tray-app module can install the providers; not part of the stable API. - */ -object TrayScreenGeometry { - /** Primary monitor scale factor provider (installed by the tray-app module). */ - @Volatile - var scaleProvider: (() -> Float)? = null - - /** - * Primary monitor work-area provider in **physical** pixels as `[x, y, width, height]` - * (installed by the tray-app module). Returns `null` when unavailable. - */ - @Volatile - var workAreaPxProvider: (() -> LongArray?)? = null - - /** Primary monitor scale factor (1.0 on non-HiDPI displays or when no provider is set). */ - internal fun scale(): Float = - runCatching { scaleProvider?.invoke() ?: 1f } - .getOrDefault(1f) - .coerceAtLeast(1f) - - /** - * Primary monitor work area (screen minus taskbar/menu bar/panel) in logical pixels, top-left - * origin. Falls back to a common resolution when no provider is set or the query fails, so - * positioning degrades gracefully. - */ - internal fun workAreaLogical(): ScreenRect { - val wa = runCatching { workAreaPxProvider?.invoke() }.getOrNull() - if (wa == null || wa.size != 4) return ScreenRect(0, 0, FALLBACK_WIDTH, FALLBACK_HEIGHT) - val s = scale() - return ScreenRect( - (wa[0] / s).toInt(), - (wa[1] / s).toInt(), - (wa[2] / s).toInt(), - (wa[3] / s).toInt(), - ) - } - - private const val FALLBACK_WIDTH = 1920 - private const val FALLBACK_HEIGHT = 1080 -} diff --git a/tray-app/build.gradle.kts b/tray-app/build.gradle.kts index 042af23..8dde0e7 100644 --- a/tray-app/build.gradle.kts +++ b/tray-app/build.gradle.kts @@ -40,7 +40,8 @@ kotlin { implementation(compose.components.resources) implementation(libs.kotlinx.coroutines.core) implementation(libs.nucleus.core.runtime) - implementation(libs.nucleus.application) + // api: NucleusApplicationScope is the receiver type of the public TrayApp API. + api(libs.nucleus.application) implementation(libs.nucleus.decorated.window.tao) } } diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/PersistentAnimatedVisibility.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/PersistentAnimatedVisibility.kt similarity index 99% rename from tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/PersistentAnimatedVisibility.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/PersistentAnimatedVisibility.kt index c1e3ffd..b5e039d 100644 --- a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/PersistentAnimatedVisibility.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/PersistentAnimatedVisibility.kt @@ -1,4 +1,4 @@ -package dev.nucleusframework.composenativetray.tray.api +package dev.nucleusframework.composenativetray.trayapp import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.EnterExitState diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt similarity index 95% rename from tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayApp.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayApp.kt index 67506f7..8ac46d5 100644 --- a/tray-app/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,19 +43,15 @@ 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.TrayScreenGeometry -import dev.nucleusframework.composenativetray.utils.getTrayWindowPosition -import dev.nucleusframework.composenativetray.utils.getTrayWindowPositionForInstance import dev.nucleusframework.composenativetray.utils.isMenuBarInDarkMode import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.tao.TaoScreenGeometry import dev.nucleusframework.window.tao.TaoStandalonePopup -import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.isTaoStandalonePopupAvailable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -65,23 +61,6 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference - -// --------------------- Tao-backed screen geometry --------------------- - -// The core module's `TrayScreenGeometry` (used by the positioning code and the native tray -// managers) is windowing-backend agnostic; the tray-app module installs the actual Tao-backed -// providers here so the `decorated-window-tao` dependency stays out of the core artifact. -// -// On Linux the GDK work-area query needs a realized window; the opaque-window impl publishes the -// popup's TaoWindow through this holder. Windows/macOS resolve the primary monitor from a null -// window, exactly as before the split. -private val taoGeometryWindow = AtomicReference(null) - -private fun installTaoGeometryProviders() { - TrayScreenGeometry.scaleProvider = { TaoScreenGeometry.primaryMonitorScaleFactor(taoGeometryWindow.get()) } - TrayScreenGeometry.workAreaPxProvider = { TaoScreenGeometry.primaryMonitorWorkAreaPx(taoGeometryWindow.get()) } -} // --------------------- Public API (defaults) --------------------- @@ -465,10 +444,6 @@ fun NucleusApplicationScope.TrayApp( return } - // Back the core's windowing-agnostic geometry with the Tao bridge (before any impl composes, - // so the initial window position query resolves against real screen bounds). - remember { installTaoGeometryProviders() } - // Linux gates on runtime availability: the panel is a raw X11 window, // reachable through XWayland on Wayland sessions but absent on rare // Wayland-only setups β€” those fall back to the opaque window impl. @@ -909,8 +884,8 @@ private fun NucleusApplicationScope.TrayAppImplWindow( // Give the position code access to the popup's TaoWindow (needed by // the GDK-backed work-area queries on Linux). DisposableEffect(window) { - taoGeometryWindow.set(window.unsafe.taoWindow) - onDispose { taoGeometryWindow.set(null) } + TrayScreenGeometry.taoWindowProvider = { window.unsafe.taoWindow } + onDispose { TrayScreenGeometry.taoWindowProvider = null } } // On show: raise + focus so focus-loss dismissal works. diff --git a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppLogging.kt similarity index 59% rename from tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt rename to tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppLogging.kt index ce7685a..3aa7e1c 100644 --- a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/api/TrayAppLogging.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayAppLogging.kt @@ -1,20 +1,27 @@ -package dev.nucleusframework.composenativetray.tray.api +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. +// 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("[${java.time.LocalDateTime.now()}] ${message()}") + println("[${timestamp()}] ${message()}") } } internal fun errorln(message: () -> String) { if (allowComposeNativeTrayLogging && composeNativeTrayLoggingLevel <= ComposeNativeTrayLoggingLevel.ERROR) { - println("[${java.time.LocalDateTime.now()}] ${message()}") + println("[${timestamp()}] ${message()}") } } diff --git a/tray-app/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 tray-app/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/tray-app/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/TrayScreenGeometry.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayScreenGeometry.kt new file mode 100644 index 0000000..e67333a --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayScreenGeometry.kt @@ -0,0 +1,52 @@ +package dev.nucleusframework.composenativetray.trayapp + +import dev.nucleusframework.window.tao.TaoScreenGeometry +import dev.nucleusframework.window.tao.TaoWindow + +internal data class ScreenRect( + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) + +/** + * Screen geometry backed by the Tao windowing backend. 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. + */ +internal object TrayScreenGeometry { + @Volatile + var taoWindowProvider: (() -> TaoWindow?)? = null + + private fun taoWindow(): TaoWindow? = taoWindowProvider?.invoke() + + /** Primary monitor scale factor (1.0 on non-HiDPI displays). */ + fun scale(): Float = + runCatching { TaoScreenGeometry.primaryMonitorScaleFactor(taoWindow()) } + .getOrDefault(1f) + .coerceAtLeast(1f) + + /** + * Primary monitor work area (screen minus taskbar/menu bar/panel) in logical pixels, top-left + * origin. 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() + if (wa == null || wa.size != 4) return ScreenRect(0, 0, FALLBACK_WIDTH, FALLBACK_HEIGHT) + val s = scale() + return ScreenRect( + (wa[0] / s).toInt(), + (wa[1] / s).toInt(), + (wa[2] / s).toInt(), + (wa[3] / s).toInt(), + ) + } + + private const val FALLBACK_WIDTH = 1920 + private const val FALLBACK_HEIGHT = 1080 +} diff --git a/tray-app/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 tray-app/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/tray-app/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..6b78ca7 --- /dev/null +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt @@ -0,0 +1,377 @@ +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.composenativetray.utils.TrayPosition +import dev.nucleusframework.composenativetray.utils.getTrayPosition +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) { + macRegionToCorner(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 + } +} + +private fun macRegionToCorner(nativeResult: String?): TrayPosition = + when (nativeResult) { + "top-left" -> TrayPosition.TOP_LEFT + "top-right" -> TrayPosition.TOP_RIGHT + else -> TrayPosition.TOP_RIGHT + } + +@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) } } +} From 31585bbf7f94673937d0d1bd6e08f4a7da98ffd3 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Tue, 14 Jul 2026 13:52:41 +0300 Subject: [PATCH 3/3] refactor: move getTrayPosition + TrayPosition enum into tray-app getTrayPosition() and the TrayPosition enum now live in composenativetray-app (package ...trayapp), next to the popup positioning they feed, so the core artifact exposes no tray-corner / positioning API at all. The native tray-region queries are reached through typed wrappers (WindowsTrayInitializer.notificationIconsRegion, MacTrayInitializer.statusItemRegion) so the JNI bridges stay internal to core. Core's TrayPosition.kt is reduced to the raw click tracker (TrayClickTracker + TrayClickPoint) and renamed TrayClickTracker.kt. Demos and README updated for the new package. This also removes the Linux core-only degradation: getTrayPosition now always has Tao geometry available in tray-app. --- README.md | 11 ++- .../demo/DemoAdaptivePositionWindows.kt | 2 +- .../demo/DemoWithContextMenu.kt | 2 +- .../demo/DemoWithImageVector.kt | 2 +- .../composenativetray/demo/DemoWithPainter.kt | 2 +- .../demo/DemoWithPlatformSpecificIcons.kt | 2 +- .../demo/DemoWithoutContextMenu.kt | 2 +- .../composenativetray/demo/DynamicTrayMenu.kt | 2 +- .../tray/impl/MacTrayInitializer.kt | 3 + .../tray/impl/WindowsTrayInitializer.kt | 9 ++ .../utils/TrayClickTracker.kt | 44 +++++++++ .../composenativetray/utils/TrayPosition.kt | 92 ------------------- .../composenativetray/trayapp/TrayPosition.kt | 48 ++++++++++ .../trayapp/TrayWindowPosition.kt | 11 +-- 14 files changed, 118 insertions(+), 114 deletions(-) create mode 100644 src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayClickTracker.kt delete mode 100644 src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt create mode 100644 tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayPosition.kt diff --git a/README.md b/README.md index afad23d..0213d26 100644 --- a/README.md +++ b/README.md @@ -463,7 +463,11 @@ nucleusApplication { ### πŸ“ Position Detection -`getTrayPosition()` (in the core artifact) tells you which screen corner the tray icon sits in: +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. + +`getTrayPosition()` tells you which screen corner the tray icon sits in: ```kotlin val corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LEFT / BOTTOM_RIGHT @@ -472,10 +476,7 @@ val corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LE - **Windows / macOS**: resolved from the native tray/menu-bar region. - **Linux**: uses the desktop-environment convention (no reliable native tray-region API). -To compute a precise window position anchored to the tray icon, use `getTrayWindowPosition(...)`. Because -it needs screen geometry (the Tao backend), it lives in the **`composenativetray-app`** artifact -(package `dev.nucleusframework.composenativetray.trayapp`) β€” the same one that provides `TrayApp`, -which uses it internally: +`getTrayWindowPosition(...)` computes a precise window position anchored to the tray icon: ```kotlin val windowPosition = getTrayWindowPosition(windowWidth = 800, windowHeight = 600) 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 2911de1..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,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.trayapp.getTrayWindowPosition import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.window.NucleusDecoratedWindowTheme 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/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt index 1219e3e..f45ba55 100644 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt +++ b/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/tray/impl/MacTrayInitializer.kt @@ -26,6 +26,9 @@ object MacTrayInitializer { 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( 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 91d9626..0000000 --- a/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/utils/TrayPosition.kt +++ /dev/null @@ -1,92 +0,0 @@ -package dev.nucleusframework.composenativetray.utils - -import dev.nucleusframework.composenativetray.lib.mac.MacNativeBridge -import dev.nucleusframework.composenativetray.lib.windows.WindowsNativeBridge -import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment -import dev.nucleusframework.core.runtime.Platform -import java.util.Collections -import java.util.concurrent.atomic.AtomicReference - -enum class TrayPosition { TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT } - -/** 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] -} - -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 (no screen geometry needed). Linux - * has no reliable native tray-region API, so it uses the desktop-environment convention. Precise, - * click-derived Linux positioning is provided by `TrayApp` (composenativetray-app), which owns the - * Tao screen geometry. - */ -fun getTrayPosition(): TrayPosition = - when (Platform.Current) { - Platform.Windows -> getWindowsTrayPosition(WindowsNativeBridge.nativeGetNotificationIconsRegion()) - Platform.MacOS -> getMacTrayPosition(MacNativeBridge.nativeGetStatusItemRegion()) - 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/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/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt index 6b78ca7..111f407 100644 --- a/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt +++ b/tray-app/src/jvmMain/kotlin/dev/nucleusframework/composenativetray/trayapp/TrayWindowPosition.kt @@ -6,8 +6,6 @@ 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.composenativetray.utils.TrayPosition -import dev.nucleusframework.composenativetray.utils.getTrayPosition import dev.nucleusframework.core.runtime.Platform import java.io.File import java.util.Properties @@ -151,7 +149,7 @@ fun getTrayWindowPositionForInstance( val regionStr = MacTrayInitializer.statusItemRegionFor(instanceId) val trayPos = if (regionStr != null) { - macRegionToCorner(regionStr) + getMacTrayPosition(regionStr) } else { val bounds = getScreenBoundsAt(x, y) convertPositionToCorner(x - bounds.x, y - bounds.y, bounds.width, bounds.height) @@ -202,13 +200,6 @@ internal fun convertPositionToCorner( } } -private fun macRegionToCorner(nativeResult: String?): TrayPosition = - when (nativeResult) { - "top-left" -> TrayPosition.TOP_LEFT - "top-right" -> TrayPosition.TOP_RIGHT - else -> TrayPosition.TOP_RIGHT - } - @Suppress("UNUSED_PARAMETER") private fun getScreenBoundsAt( x: Int,