@@ -259,10 +259,11 @@ fun ApplicationScope.TrayApp(
259259 *
260260 * Key features:
261261 * - Creates a system tray icon with customizable appearance
262- * - Manages a popup window that preserves state when hidden
262+ * - Manages a popup window that preserves ALL states (including remember values)
263263 * - Handles platform-specific behaviors (Windows, macOS, Linux)
264264 * - Provides smooth fade animations
265265 * - Manages focus and outside-click behaviors
266+ * - Uses a hybrid approach: window stays mounted but is moved off-screen when hidden
266267 */
267268@ExperimentalTrayAppApi
268269@Composable
@@ -283,6 +284,9 @@ fun ApplicationScope.TrayApp(
283284 // Internal state for managing window display with animation
284285 var shouldShowWindow by remember { mutableStateOf(false ) }
285286
287+ // Track if window has been created at least once
288+ var windowCreated by remember { mutableStateOf(false ) }
289+
286290 // System information
287291 val isDark = isMenuBarInDarkMode()
288292 val os = getOperatingSystem()
@@ -389,6 +393,7 @@ fun ApplicationScope.TrayApp(
389393 if (isVisible) {
390394 // Show window immediately
391395 shouldShowWindow = true
396+ windowCreated = true
392397 lastShownAt = System .currentTimeMillis()
393398 } else {
394399 // Hide window after fade animation completes
@@ -436,6 +441,7 @@ fun ApplicationScope.TrayApp(
436441 }
437442
438443 shouldShowWindow = true
444+ windowCreated = true
439445 lastShownAt = System .currentTimeMillis()
440446 }
441447 }
@@ -459,95 +465,112 @@ fun ApplicationScope.TrayApp(
459465 focusable = false ,
460466 ) { }
461467
462- // Main popup window - ALWAYS in composition to preserve state
463- // The window is always composed but visibility is controlled
464- val widthPx = windowSize.width.value.toInt()
465- val heightPx = windowSize.height.value.toInt()
466- val windowPosition = getTrayWindowPositionForInstance(tray.instanceKey(), widthPx, heightPx)
468+ // Main popup window - always mounted once created to preserve states
469+ // Uses hybrid approach: moves off-screen instead of unmounting
470+ if (windowCreated) {
471+ // Calculate position: off-screen when hidden, correct position when visible
472+ val widthPx = windowSize.width.value.toInt()
473+ val heightPx = windowSize.height.value.toInt()
474+ val windowPosition = if (shouldShowWindow) {
475+ // Visible: use correct position near tray icon
476+ getTrayWindowPositionForInstance(tray.instanceKey(), widthPx, heightPx)
477+ } else {
478+ // Hidden: move far off-screen to avoid taskbar appearance
479+ WindowPosition (- 10000 .dp, - 10000 .dp)
480+ }
467481
468- DialogWindow (
469- onCloseRequest = { requestHide() },
470- title = " " ,
471- undecorated = true ,
472- resizable = false ,
473- focusable = true ,
474- alwaysOnTop = true ,
475- transparent = true ,
476- visible = shouldShowWindow, // Control visibility without unmounting
477- state = rememberDialogState(position = windowPosition, size = windowSize)
478- ) {
479- // One-time setup for window behaviors and listeners
480- DisposableEffect (Unit ) {
481- // Set window name for monitoring
482- try { window.name = WindowVisibilityMonitor .TRAY_DIALOG_NAME } catch (_: Throwable ) {}
483-
484- // Focus listener for auto-hide on focus loss
485- val focusListener = object : WindowFocusListener {
486- override fun windowGainedFocus (e : WindowEvent ? ) = Unit
487- override fun windowLostFocus (e : WindowEvent ? ) {
488- lastFocusLostAt = System .currentTimeMillis()
489- // Windows: Ignore focus loss during startup period
490- if (os == WINDOWS && lastFocusLostAt < autoHideEnabledAt) {
491- return
482+ DialogWindow (
483+ onCloseRequest = { requestHide() },
484+ title = " " ,
485+ undecorated = true ,
486+ resizable = false ,
487+ focusable = shouldShowWindow, // Only focusable when visible
488+ alwaysOnTop = shouldShowWindow, // Only on top when visible
489+ transparent = true ,
490+ visible = true , // Always visible to the system
491+ state = rememberDialogState(position = windowPosition, size = windowSize)
492+ ) {
493+ DisposableEffect (Unit ) {
494+ // Set window name for monitoring
495+ try { window.name = WindowVisibilityMonitor .TRAY_DIALOG_NAME } catch (_: Throwable ) {}
496+
497+ // Focus listener for auto-hide on focus loss
498+ val focusListener = object : WindowFocusListener {
499+ override fun windowGainedFocus (e : WindowEvent ? ) = Unit
500+ override fun windowLostFocus (e : WindowEvent ? ) {
501+ lastFocusLostAt = System .currentTimeMillis()
502+ // Windows: Ignore focus loss during startup period
503+ if (os == WINDOWS && lastFocusLostAt < autoHideEnabledAt) {
504+ return
505+ }
506+ if (shouldShowWindow) { // Only hide if currently visible
507+ requestHide()
508+ }
492509 }
493- requestHide()
494510 }
495- }
496511
497- // macOS-specific outside click detection
498- val macWatcher = if (getOperatingSystem() == MACOS ) {
499- MacOutsideClickWatcher (
500- windowSupplier = { window },
501- onOutsideClick = { invokeLater { requestHide() } }
502- ).also { it.start() }
503- } else null
504-
505- // Linux-specific outside click detection
506- val linuxWatcher = if (getOperatingSystem() == OperatingSystem .LINUX ) {
507- LinuxOutsideClickWatcher (
508- windowSupplier = { window },
509- onOutsideClick = { invokeLater { requestHide() } }
510- ).also { it.start() }
511- } else null
512-
513- window.addWindowFocusListener(focusListener)
514-
515- // Cleanup on disposal
516- onDispose {
517- window.removeWindowFocusListener(focusListener)
518- macWatcher?.stop()
519- linuxWatcher?.stop()
512+ // Platform-specific outside click detection
513+ val macWatcher = if (getOperatingSystem() == MACOS ) {
514+ MacOutsideClickWatcher (
515+ windowSupplier = { window },
516+ onOutsideClick = {
517+ if (shouldShowWindow) { // Only hide if currently visible
518+ invokeLater { requestHide() }
519+ }
520+ }
521+ ).also { it.start() }
522+ } else null
523+
524+ val linuxWatcher = if (getOperatingSystem() == OperatingSystem .LINUX ) {
525+ LinuxOutsideClickWatcher (
526+ windowSupplier = { window },
527+ onOutsideClick = {
528+ if (shouldShowWindow) { // Only hide if currently visible
529+ invokeLater { requestHide() }
530+ }
531+ }
532+ ).also { it.start() }
533+ } else null
534+
535+ window.addWindowFocusListener(focusListener)
536+
537+ // Cleanup on disposal
538+ onDispose {
539+ window.removeWindowFocusListener(focusListener)
540+ macWatcher?.stop()
541+ linuxWatcher?.stop()
542+ }
520543 }
521- }
522544
523- // Handle visibility state changes
524- LaunchedEffect (shouldShowWindow) {
525- if (shouldShowWindow) {
526- // Window is becoming visible
527- runCatching { WindowVisibilityMonitor .recompute() }
528- invokeLater {
529- try {
530- // Bring window to front and request focus
531- window.toFront()
532- window.requestFocus()
533- window.requestFocusInWindow()
534- } catch (_: Throwable ) {
545+ // React to visibility changes
546+ LaunchedEffect (shouldShowWindow) {
547+ if (shouldShowWindow) {
548+ // Window is becoming visible
549+ runCatching { WindowVisibilityMonitor .recompute() }
550+ invokeLater {
551+ try {
552+ // Bring window to front and request focus
553+ window.toFront()
554+ window.requestFocus()
555+ window.requestFocusInWindow()
556+ } catch (_: Throwable ) {
557+ }
535558 }
559+ } else {
560+ // Window is becoming hidden
561+ runCatching { WindowVisibilityMonitor .recompute() }
536562 }
537- } else {
538- // Window is becoming hidden
539- runCatching { WindowVisibilityMonitor .recompute() }
540563 }
541- }
542564
543- // Content wrapper with fade animation
544- Box (
545- modifier = Modifier
546- .fillMaxSize()
547- .alpha(alpha) // Apply fade animation
548- ) {
549- // User content - always mounted to preserve state
550- content()
565+ // Content wrapper with fade animation
566+ Box (
567+ modifier = Modifier
568+ .fillMaxSize()
569+ .alpha(alpha) // Apply fade animation
570+ ) {
571+ // Content is always mounted, preserving all states
572+ content()
573+ }
551574 }
552575 }
553576}
0 commit comments