Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 64 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>")

// 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:<version>")
}
```

> 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"
Expand All @@ -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

Expand Down Expand Up @@ -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 */) {
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -411,84 +424,66 @@ The single instance manager combined with the primary action (left-click) is par
- Preserving the current state of the application during restoration
- Offering behavior similar to native system applications

Implementation example with `SingleInstanceManager` (provided by Nucleus):
Single instance is **enabled by default** in `nucleusApplication`; pass `enableSingleInstance = false`
to opt out. When a second launch is detected, the already-running instance is notified through
`SingleInstanceRestoreEffect` — restore your window (or re-open the tray popup) there instead of
starting a new process:

```kotlin
import dev.nucleusframework.core.runtime.SingleInstanceManager
import dev.nucleusframework.application.SingleInstanceRestoreEffect

var isWindowVisible by remember { mutableStateOf(true) }
nucleusApplication(enableSingleInstance = true) { // true is the default
var isWindowVisible by remember { mutableStateOf(true) }

val isSingleInstance = SingleInstanceManager.isSingleInstance(
onRestoreRequest = {
isWindowVisible = true // Restore the existing window
// Runs in the already-running instance each time the app is launched again.
SingleInstanceRestoreEffect {
isWindowVisible = true // bring the existing window / tray popup back
}
)

if (!isSingleInstance) {
exitApplication()
return@application
// ... Tray / TrayApp / windows
}
```

#### Passing data to the main instance

In some cases, you may want to pass some data to the main instance, e.g. pass a deeplink,
that new instance got in the arguments of the `main` function from OS.
See `TrayAppDemo.kt` for a working example (a second launch re-opens the tray popup).

For this purpose you can use optional `onRestoreFileCreated` handler to write required data to the special file,
that will be later accessible to read in the `onRestoreRequest` handler of the main instance.
#### Deep links

Both handlers have the `Path` as a receiver, so you can do any read/write operations on it.
To react to a deep link the OS hands to the app (including one that arrives on a second launch while
single instance is enabled), register a handler on the application scope:

```kotlin
SingleInstanceManager.isSingleInstance(
onRestoreFileCreated = {
args.firstOrNull()?.let(::writeText)
},
onRestoreRequest = {
log("Restored with arg: '${readText()}'")
// restore window/etc.
}
)
nucleusApplication {
onDeepLink { uri ->
// handle the incoming URI (navigate, restore state, …)
}

// ... Tray / TrayApp / windows
}
```

#### Custom Configuration
### 📍 Position Detection

Tray positioning needs screen geometry (the Tao backend), so these helpers live in the
**`composenativetray-app`** artifact (package `dev.nucleusframework.composenativetray.trayapp`) —
the same one that provides `TrayApp`, which uses them internally.

For finer control, configure the `SingleInstanceManager`:
`getTrayPosition()` tells you which screen corner the tray icon sits in:

```kotlin
SingleInstanceManager.configuration = Configuration(
lockFilesDir = Paths.get("path/to/your/app/data/dir/single_instance_manager"),
appIdentifier = "app_id"
)
val corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LEFT / BOTTOM_RIGHT
```

This allows limiting the scope of the single instance to a specific directory or identifying different versions of your application.

### 📍 Position Detection
- **Windows / macOS**: resolved from the native tray/menu-bar region.
- **Linux**: uses the desktop-environment convention (no reliable native tray-region API).

Precisely position your windows relative to the system tray icon:
`getTrayWindowPosition(...)` computes a precise window position anchored to the tray icon:

```kotlin
val windowWidth = 800
val windowHeight = 600
val windowPosition = getTrayWindowPosition(windowWidth, windowHeight)

Window(
state = rememberWindowState(
width = windowWidth.dp,
height = windowHeight.dp,
position = windowPosition
)
) { /* content */ }
val windowPosition = getTrayWindowPosition(windowWidth = 800, windowHeight = 600)
```

**Implementation Details:**
- **Windows**: Uses the Windows native API to get the exact position
- **macOS**: Uses the Cocoa API for the position in the menu bar
- **Linux**: Captures coordinates when clicking on the icon

The window is automatically horizontally centered on the icon and vertically positioned based on whether the system tray is at the top or bottom of the screen.
The window is horizontally centered on the icon and vertically anchored to the top or bottom of the
screen depending on where the tray lives. For a ready-made tray + popup window, prefer `TrayApp`.

### 🌓 Dark Mode Detection

Expand Down Expand Up @@ -575,6 +570,16 @@ Add the following to your ProGuard rules file:

**Works on Windows, macOS, and Linux.** Smooth fade animations, smart positioning near the tray, and a simple API so you stay productive.

> **📦 Dependency:** `TrayApp` lives in a separate artifact so basic-tray users don't pull in the
> windowing backend. Add it in addition to the core:
> ```kotlin
> implementation("dev.nucleusframework:composenativetray-app:<version>")
> ```
> It requires the Nucleus Tao backend — launch your app with `nucleusApplication { … }`. `TrayApp`,
> `TrayAppState`, `rememberTrayAppState` and `TrayWindowDismissMode` live in the
> `dev.nucleusframework.composenativetray.trayapp` package. See the `TrayAppDemo` in the `demo`
> module for a complete example.

---

## Why you’ll like it
Expand All @@ -591,7 +596,7 @@ Add the following to your ProGuard rules file:
## Quick Start (minimal)

```kotlin
application {
nucleusApplication {
val trayAppState = rememberTrayAppState(
initialWindowSize = DpSize(300.dp, 420.dp),
initiallyVisible = true // default is false
Expand All @@ -604,7 +609,6 @@ application {
tooltip = "My Tray App", // required

// Optional visual controls (defaults shown below)
transparent = true, // default = true
undecorated = true, // default = true
resizable = false, // default = false
windowsTitle = "My Tray Popup", // default = "" — recommended (esp. on Linux & when undecorated=false)
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ kotlin {
implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.darkmode.detector)
api(libs.nucleus.application)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can it be replaced with the core-runtime instead?

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"))
Expand Down
1 change: 1 addition & 0 deletions demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":"))
implementation(project(":tray-app"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.desktop.currentOs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import dev.nucleusframework.composenativetray.tray.api.Tray
import dev.nucleusframework.composenativetray.utils.ComposeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.allowComposeNativeTrayLogging
import dev.nucleusframework.composenativetray.utils.composeNativeTrayLoggingLevel
import dev.nucleusframework.composenativetray.utils.getTrayPosition
import dev.nucleusframework.composenativetray.utils.getTrayWindowPosition
import dev.nucleusframework.composenativetray.trayapp.getTrayPosition
import dev.nucleusframework.composenativetray.trayapp.getTrayWindowPosition
import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
import dev.nucleusframework.window.NucleusDecoratedWindowTheme
import dev.nucleusframework.window.TitleBar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ dependencyResolutionManagement {
}
}

include(":tray-app")
include(":demo")
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading