Skip to content

Commit f6df296

Browse files
committed
feat: add onMenuOpened callback to Tray composable (#233)
Add a callback invoked when the tray menu is about to be shown, allowing apps to refresh state on demand. Implemented via the DBusMenu AboutToShow protocol on Linux; noop on Windows/macOS. Includes LayoutUpdated-based suppression to prevent feedback loops when the callback mutates menu state, and build script cache invalidation to avoid stale native library issues.
1 parent 7cc7266 commit f6df296

11 files changed

Lines changed: 145 additions & 21 deletions

File tree

demo/src/jvmMain/kotlin/com/kdroid/composetray/demo/DynamicIconsDemo.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,19 @@ fun main() = application {
5656

5757
// Basic state for tray icon
5858
var currentTrayIcon by remember { mutableStateOf(Icons.Default.Notifications) }
59-
59+
6060
// States for menu item icons
6161
var weatherIcon by remember { mutableStateOf(Icons.Default.WbSunny) }
6262
var musicIcon by remember { mutableStateOf(Icons.Default.MusicNote) }
6363
var settingsIcon by remember { mutableStateOf(Icons.Default.Settings) }
64-
64+
6565
// States for theme and features
6666
var isDarkTheme by remember { mutableStateOf(false) }
6767
var isWeatherEnabled by remember { mutableStateOf(true) }
6868
var isMusicEnabled by remember { mutableStateOf(true) }
69+
70+
// Counter to demonstrate onMenuOpened callback
71+
var menuOpenCount by remember { mutableIntStateOf(0) }
6972

7073
// Always create the Tray composable, but make it conditional on visibility
7174
val showTray = alwaysShowTray || !isWindowVisible
@@ -77,7 +80,11 @@ fun main() = application {
7780
primaryAction = {
7881
isWindowVisible = true
7982
println("$logTag: Primary action clicked")
80-
}
83+
},
84+
onMenuOpened = {
85+
menuOpenCount++
86+
println("$logTag: Menu opened (count: $menuOpenCount)")
87+
},
8188
) {
8289
// Weather submenu with dynamic icon
8390
SubMenu(label = "Weather", icon = weatherIcon) {
@@ -249,6 +256,12 @@ fun main() = application {
249256

250257
Text(
251258
"This demo showcases dynamic icon changes in the system tray menu.",
259+
modifier = Modifier.padding(bottom = 8.dp)
260+
)
261+
262+
Text(
263+
"Tray menu opened $menuOpenCount times",
264+
style = MaterialTheme.typography.bodyMedium,
252265
modifier = Modifier.padding(bottom = 24.dp)
253266
)
254267

src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxNativeBridge.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ internal object LinuxNativeBridge {
6565
callback: Runnable?,
6666
)
6767

68+
/** Register a callback invoked when the menu is about to be shown. */
69+
@JvmStatic external fun nativeSetMenuOpenedCallback(
70+
handle: Long,
71+
callback: Runnable?,
72+
)
73+
6874
// -- Click position ----------------------------------------------------------
6975

7076
/** Writes [x, y] into outXY. */

src/jvmMain/kotlin/com/kdroid/composetray/lib/linux/LinuxTrayManager.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ internal class LinuxTrayManager(
2323
private var iconPath: String,
2424
private var tooltip: String = "",
2525
private var onLeftClick: (() -> Unit)? = null,
26+
private var onMenuOpened: (() -> Unit)? = null,
2627
) {
2728
companion object {
2829
// Ensures only one systray runtime is active at a time
@@ -99,6 +100,7 @@ internal class LinuxTrayManager(
99100
newTooltip: String,
100101
newOnLeftClick: (() -> Unit)?,
101102
newMenuItems: List<MenuItem>?,
103+
newOnMenuOpened: (() -> Unit)? = null,
102104
) {
103105
val iconChanged: Boolean
104106
val tooltipChanged: Boolean
@@ -109,6 +111,7 @@ internal class LinuxTrayManager(
109111
iconPath = newIconPath
110112
tooltip = newTooltip
111113
onLeftClick = newOnLeftClick
114+
onMenuOpened = newOnMenuOpened
112115
if (newMenuItems != null) {
113116
menuItems.clear()
114117
menuItems.addAll(newMenuItems)
@@ -174,6 +177,12 @@ internal class LinuxTrayManager(
174177
},
175178
)
176179

180+
// Set menu-opened callback
181+
native.nativeSetMenuOpenedCallback(
182+
trayHandle,
183+
JniRunnable { onMenuOpened?.invoke() },
184+
)
185+
177186
// Build menu before starting the loop
178187
rebuildMenu()
179188

src/jvmMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ internal class NativeTray {
5656
tooltip: String,
5757
primaryAction: (() -> Unit)?,
5858
menuContent: (TrayMenuBuilder.() -> Unit)?,
59+
onMenuOpened: (() -> Unit)? = null,
5960
) {
6061
if (!initialized) {
6162
initializeTray(iconPath, windowsIconPath, tooltip, primaryAction, menuContent)
@@ -65,16 +66,17 @@ internal class NativeTray {
6566

6667
try {
6768
when (os) {
68-
LINUX -> LinuxTrayInitializer.update(instanceId, iconPath, tooltip, primaryAction, menuContent)
69+
LINUX -> LinuxTrayInitializer.update(instanceId, iconPath, tooltip, primaryAction, menuContent, onMenuOpened)
6970
WINDOWS ->
7071
WindowsTrayInitializer.update(
7172
instanceId,
7273
windowsIconPath,
7374
tooltip,
7475
primaryAction,
7576
menuContent,
77+
onMenuOpened,
7678
)
77-
MACOS -> MacTrayInitializer.update(instanceId, iconPath, tooltip, primaryAction, menuContent)
79+
MACOS -> MacTrayInitializer.update(instanceId, iconPath, tooltip, primaryAction, menuContent, onMenuOpened)
7880
UNKNOWN -> {
7981
AwtTrayInitializer.update(iconPath, tooltip, primaryAction, menuContent)
8082
awtTrayUsed.set(true)
@@ -103,6 +105,7 @@ internal class NativeTray {
103105
backoffMs: Long = 200,
104106
lightIconContent: (@Composable () -> Unit)? = null,
105107
darkIconContent: (@Composable () -> Unit)? = null,
108+
onMenuOpened: (() -> Unit)? = null,
106109
) {
107110
trayScope.launch {
108111
val rendered = renderIconsWithRetry(iconContent, iconRenderProperties, maxAttempts, backoffMs)
@@ -117,7 +120,7 @@ internal class NativeTray {
117120
val (pngIconPath, windowsIconPath) = rendered
118121

119122
if (!initialized) {
120-
initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent)
123+
initializeTray(pngIconPath, windowsIconPath, tooltip, primaryAction, menuContent, onMenuOpened)
121124
initialized = true
122125
} else {
123126
try {
@@ -129,6 +132,7 @@ internal class NativeTray {
129132
tooltip,
130133
primaryAction,
131134
menuContent,
135+
onMenuOpened,
132136
)
133137
WINDOWS ->
134138
WindowsTrayInitializer.update(
@@ -137,8 +141,9 @@ internal class NativeTray {
137141
tooltip,
138142
primaryAction,
139143
menuContent,
144+
onMenuOpened,
140145
)
141-
MACOS -> MacTrayInitializer.update(instanceId, pngIconPath, tooltip, primaryAction, menuContent)
146+
MACOS -> MacTrayInitializer.update(instanceId, pngIconPath, tooltip, primaryAction, menuContent, onMenuOpened)
142147
UNKNOWN -> {
143148
AwtTrayInitializer.update(pngIconPath, tooltip, primaryAction, menuContent)
144149
awtTrayUsed.set(true)
@@ -241,6 +246,7 @@ internal class NativeTray {
241246
tooltip: String = "",
242247
primaryAction: (() -> Unit)?,
243248
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
249+
onMenuOpened: (() -> Unit)? = null,
244250
) {
245251
trayScope.launch {
246252
var trayInitialized = false
@@ -249,7 +255,7 @@ internal class NativeTray {
249255
when (os) {
250256
LINUX -> {
251257
debugln { "[NativeTray] Initializing Linux tray with icon path: $iconPath" }
252-
LinuxTrayInitializer.initialize(instanceId, iconPath, tooltip, primaryAction, menuContent)
258+
LinuxTrayInitializer.initialize(instanceId, iconPath, tooltip, primaryAction, menuContent, onMenuOpened)
253259
trayInitialized = true
254260
}
255261
WINDOWS -> {
@@ -260,12 +266,13 @@ internal class NativeTray {
260266
tooltip,
261267
primaryAction,
262268
menuContent,
269+
onMenuOpened,
263270
)
264271
trayInitialized = true
265272
}
266273
MACOS -> {
267274
debugln { "[NativeTray] Initializing macOS tray with icon path: $iconPath" }
268-
MacTrayInitializer.initialize(instanceId, iconPath, tooltip, primaryAction, menuContent)
275+
MacTrayInitializer.initialize(instanceId, iconPath, tooltip, primaryAction, menuContent, onMenuOpened)
269276
trayInitialized = true
270277
}
271278
else -> {}
@@ -301,13 +308,15 @@ internal class NativeTray {
301308
tooltip: String = "",
302309
primaryAction: (() -> Unit)?,
303310
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
311+
onMenuOpened: (() -> Unit)? = null,
304312
) {
305313
updateComposable(
306314
iconContent = iconContent,
307315
iconRenderProperties = iconRenderProperties,
308316
tooltip = tooltip,
309317
primaryAction = primaryAction,
310318
menuContent = menuContent,
319+
onMenuOpened = onMenuOpened,
311320
)
312321
}
313322
}
@@ -325,6 +334,7 @@ fun ApplicationScope.Tray(
325334
windowsIconPath: String = iconPath,
326335
tooltip: String,
327336
primaryAction: (() -> Unit)? = null,
337+
onMenuOpened: (() -> Unit)? = null,
328338
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
329339
) {
330340
val absoluteIconPath = remember(iconPath) { extractToTempIfDifferent(iconPath)?.absolutePath.orEmpty() }
@@ -344,7 +354,7 @@ fun ApplicationScope.Tray(
344354

345355
// Update when params change, including menuHash
346356
LaunchedEffect(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, menuContent, menuHash) {
347-
tray.update(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, menuContent)
357+
tray.update(absoluteIconPath, absoluteWindowsIconPath, tooltip, primaryAction, menuContent, onMenuOpened)
348358
}
349359

350360
// Dispose only when Tray is removed from composition
@@ -362,6 +372,7 @@ fun ApplicationScope.Tray(
362372
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
363373
tooltip: String,
364374
primaryAction: (() -> Unit)? = null,
375+
onMenuOpened: (() -> Unit)? = null,
365376
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
366377
) {
367378
val isDark = isMenuBarInDarkMode() // Observe menu bar theme to trigger recomposition on changes
@@ -384,6 +395,7 @@ fun ApplicationScope.Tray(
384395
menuContent = menuContent,
385396
maxAttempts = 3,
386397
backoffMs = 200,
398+
onMenuOpened = onMenuOpened,
387399
)
388400
}
389401

@@ -402,6 +414,7 @@ fun ApplicationScope.Tray(
402414
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
403415
tooltip: String,
404416
primaryAction: (() -> Unit)? = null,
417+
onMenuOpened: (() -> Unit)? = null,
405418
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
406419
) {
407420
val isDark = isMenuBarInDarkMode()
@@ -477,6 +490,7 @@ fun ApplicationScope.Tray(
477490
backoffMs = 200,
478491
lightIconContent = lightIconContent,
479492
darkIconContent = darkIconContent,
493+
onMenuOpened = onMenuOpened,
480494
)
481495
}
482496

@@ -494,6 +508,7 @@ fun ApplicationScope.Tray(
494508
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
495509
tooltip: String,
496510
primaryAction: (() -> Unit)? = null,
511+
onMenuOpened: (() -> Unit)? = null,
497512
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
498513
) {
499514
val isDark = isMenuBarInDarkMode() // Included for consistency, even if not used in rendering
@@ -527,6 +542,7 @@ fun ApplicationScope.Tray(
527542
menuContent = menuContent,
528543
maxAttempts = 3,
529544
backoffMs = 200,
545+
onMenuOpened = onMenuOpened,
530546
)
531547
}
532548

@@ -549,6 +565,7 @@ fun ApplicationScope.Tray(
549565
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
550566
tooltip: String,
551567
primaryAction: (() -> Unit)? = null,
568+
onMenuOpened: (() -> Unit)? = null,
552569
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
553570
) {
554571
val os = getOperatingSystem()
@@ -560,6 +577,7 @@ fun ApplicationScope.Tray(
560577
iconRenderProperties = iconRenderProperties,
561578
tooltip = tooltip,
562579
primaryAction = primaryAction,
580+
onMenuOpened = onMenuOpened,
563581
menuContent = menuContent,
564582
)
565583
} else {
@@ -570,6 +588,7 @@ fun ApplicationScope.Tray(
570588
iconRenderProperties = iconRenderProperties,
571589
tooltip = tooltip,
572590
primaryAction = primaryAction,
591+
onMenuOpened = onMenuOpened,
573592
menuContent = menuContent,
574593
)
575594
}
@@ -584,6 +603,7 @@ fun ApplicationScope.Tray(
584603
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
585604
tooltip: String,
586605
primaryAction: (() -> Unit)? = null,
606+
onMenuOpened: (() -> Unit)? = null,
587607
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
588608
) {
589609
// Convert DrawableResource to Painter and delegate to the Painter overload
@@ -593,6 +613,7 @@ fun ApplicationScope.Tray(
593613
iconRenderProperties = iconRenderProperties,
594614
tooltip = tooltip,
595615
primaryAction = primaryAction,
616+
onMenuOpened = onMenuOpened,
596617
menuContent = menuContent,
597618
)
598619
}
@@ -605,6 +626,7 @@ fun ApplicationScope.Tray(
605626
iconRenderProperties: IconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(),
606627
tooltip: String,
607628
primaryAction: (() -> Unit)? = null,
629+
onMenuOpened: (() -> Unit)? = null,
608630
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
609631
) {
610632
val os = getOperatingSystem()
@@ -617,6 +639,7 @@ fun ApplicationScope.Tray(
617639
iconRenderProperties = iconRenderProperties,
618640
tooltip = tooltip,
619641
primaryAction = primaryAction,
642+
onMenuOpened = onMenuOpened,
620643
menuContent = menuContent,
621644
)
622645
} else {
@@ -627,6 +650,7 @@ fun ApplicationScope.Tray(
627650
iconRenderProperties = iconRenderProperties,
628651
tooltip = tooltip,
629652
primaryAction = primaryAction,
653+
onMenuOpened = onMenuOpened,
630654
menuContent = menuContent,
631655
)
632656
}

0 commit comments

Comments
 (0)