Skip to content

Commit 09e7c0a

Browse files
authored
Merge pull request #316 from kdroidFilter/tray-app-window-dismiss-mode
Introduce `TrayWindowDismissMode` for dismiss behavior control
2 parents 9c71e50 + 375c25c commit 09e7c0a

5 files changed

Lines changed: 130 additions & 72 deletions

File tree

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,48 @@ trayAppState.setWindowSize(400.dp, 600.dp)
646646
trayAppState.setWindowSize(DpSize(350.dp, 500.dp))
647647
```
648648

649+
## 🧩 New: Tray Window Dismiss Modes
650+
651+
By default, the `TrayApp` popup window closes automatically when it loses focus or when the user clicks outside of it.
652+
With the new `TrayWindowDismissMode` API, you can choose between:
653+
654+
* **AUTO** (default): The popup closes automatically when focus is lost or when clicking outside.
655+
* **MANUAL**: The popup remains visible until you explicitly call `trayAppState.hide()`.
656+
657+
### Example
658+
659+
```kotlin
660+
@OptIn(ExperimentalTrayAppApi::class)
661+
application {
662+
val trayAppState = rememberTrayAppState(
663+
initialWindowSize = DpSize(300.dp, 400.dp),
664+
initiallyVisible = false,
665+
initialDismissMode = TrayWindowDismissMode.MANUAL // 👈 Manual mode
666+
)
667+
668+
TrayApp(
669+
state = trayAppState,
670+
icon = Icons.Default.Settings,
671+
tooltip = "Quick Settings"
672+
) {
673+
Column {
674+
Text("This popup will NOT auto-close")
675+
Button(onClick = { trayAppState.hide() }) {
676+
Text("Close manually")
677+
}
678+
}
679+
}
680+
}
681+
```
682+
683+
### Switching at runtime
684+
685+
```kotlin
686+
LaunchedEffect(Unit) {
687+
trayAppState.setDismissMode(TrayWindowDismissMode.AUTO)
688+
}
689+
```
690+
649691
### Advanced Examples
650692

651693
#### Example 1: Control from Main Window

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.ui.window.application
1919
import androidx.compose.ui.window.rememberWindowState
2020
import com.kdroid.composetray.tray.api.ExperimentalTrayAppApi
2121
import com.kdroid.composetray.tray.api.TrayApp
22+
import com.kdroid.composetray.tray.api.TrayWindowDismissMode
2223
import com.kdroid.composetray.tray.api.rememberTrayAppState
2324
import com.kdroid.composetray.utils.WindowRaise
2425
import com.kdroid.composetray.utils.allowComposeNativeTrayLogging
@@ -41,7 +42,8 @@ fun main() {
4142
// Create TrayAppState with initial settings
4243
val trayAppState = rememberTrayAppState(
4344
initialWindowSize = DpSize(300.dp, 500.dp),
44-
initiallyVisible = true
45+
initiallyVisible = true,
46+
initialDismissMode = TrayWindowDismissMode.AUTO
4547
)
4648

4749
// Observe visibility changes

src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,15 @@ fun ApplicationScope.TrayApp(
290290
// Create or use provided state
291291
val trayAppState = state ?: rememberTrayAppState(
292292
initialWindowSize = windowSize ?: DpSize(300.dp, 200.dp),
293-
initiallyVisible = visibleOnStart
293+
initiallyVisible = visibleOnStart,
294+
// Default remains AUTO to keep backward compatibility
295+
initialDismissMode = TrayWindowDismissMode.AUTO
294296
)
295297

296298
// Collect state flows
297299
val isVisible by trayAppState.isVisible.collectAsState()
298300
val currentWindowSize by trayAppState.windowSize.collectAsState()
301+
val dismissMode by trayAppState.dismissMode.collectAsState()
299302

300303
var shouldShowWindow by remember { mutableStateOf(false) }
301304

@@ -351,8 +354,9 @@ fun ApplicationScope.TrayApp(
351354
dialogState.size = currentWindowSize
352355
}
353356

354-
// Helper to request hide with a minimum visible duration guard
355-
val requestHide: () -> Unit = {
357+
// Helper to request hide with a minimum visible duration guard.
358+
// NOTE: This is used for explicit hide (tray click toggle or programmatic hide).
359+
val requestHideExplicit: () -> Unit = {
356360
val now = System.currentTimeMillis()
357361
val sinceShow = now - lastShownAt
358362
if (sinceShow >= minVisibleDurationMs) {
@@ -375,8 +379,8 @@ fun ApplicationScope.TrayApp(
375379
lastPrimaryActionAt = now
376380

377381
if (isVisible) {
378-
// Simplified hide logic for primary action
379-
requestHide()
382+
// Explicit hide (works both in AUTO and MANUAL)
383+
requestHideExplicit()
380384
} else {
381385
if (now - lastHiddenAt >= minHiddenDurationMs) {
382386
if (os == WINDOWS && (now - lastFocusLostAt) < 300) {
@@ -390,7 +394,7 @@ fun ApplicationScope.TrayApp(
390394
}
391395
}
392396

393-
// FIX: Consolidated visibility handling with position calculation BEFORE showing the window.
397+
// Consolidated visibility handling with position calculation BEFORE showing the window.
394398
LaunchedEffect(isVisible) {
395399
if (isVisible) {
396400
if (!shouldShowWindow) {
@@ -406,7 +410,6 @@ fun ApplicationScope.TrayApp(
406410
)
407411
delay(150)
408412
}
409-
// If still default after timeout, proceed anyway to avoid invisible window
410413
dialogState.position = position
411414

412415
if (os == WINDOWS) {
@@ -445,7 +448,7 @@ fun ApplicationScope.TrayApp(
445448

446449
// Invisible helper window (Compose requirement on some platforms)
447450
DialogWindow(
448-
onCloseRequest = { },
451+
onCloseRequest = { /* noop */ },
449452
visible = false,
450453
state = rememberDialogState(
451454
size = DpSize(1.dp, 1.dp),
@@ -459,7 +462,8 @@ fun ApplicationScope.TrayApp(
459462

460463
// === Main popup window (ALWAYS MOUNTED) ===
461464
DialogWindow(
462-
onCloseRequest = { requestHide() },
465+
// Closing the popup via OS/ESC is considered explicit user intent → allowed in MANUAL
466+
onCloseRequest = { requestHideExplicit() },
463467
title = "",
464468
undecorated = true,
465469
resizable = false,
@@ -469,8 +473,9 @@ fun ApplicationScope.TrayApp(
469473
visible = shouldShowWindow,
470474
state = dialogState,
471475
) {
472-
// Attach/Detach platform listeners only while window is actually visible
473-
DisposableEffect(shouldShowWindow) {
476+
// Attach/Detach platform listeners only while window is visible OR when mode changes.
477+
// Including dismissMode in the key ensures watchers reconfigure when switching AUTO <-> MANUAL.
478+
DisposableEffect(shouldShowWindow, dismissMode) {
474479
if (!shouldShowWindow) {
475480
onDispose { }
476481
return@DisposableEffect onDispose { }
@@ -479,43 +484,43 @@ fun ApplicationScope.TrayApp(
479484
// Mark this as the tray popup (macOS visibility monitor)
480485
try {
481486
window.name = WindowVisibilityMonitor.TRAY_DIALOG_NAME
482-
} catch (_: Throwable) {
483-
}
487+
} catch (_: Throwable) { }
484488
runCatching { WindowVisibilityMonitor.recompute() }
485489

486490
// Bring to front on open
487491
invokeLater {
488-
try {
492+
runCatching {
489493
window.toFront()
490494
window.requestFocus()
491495
window.requestFocusInWindow()
492-
} catch (_: Throwable) {
493496
}
494497
}
495498

499+
// Focus listener: auto-hide only if AUTO mode
496500
val focusListener = object : WindowFocusListener {
497501
override fun windowGainedFocus(e: WindowEvent?) = Unit
498502
override fun windowLostFocus(e: WindowEvent?) {
499503
lastFocusLostAt = System.currentTimeMillis()
500-
if (os == WINDOWS && lastFocusLostAt < autoHideEnabledAt) {
501-
// Ignore focus loss during startup grace period on Windows
502-
return
504+
if (os == WINDOWS && lastFocusLostAt < autoHideEnabledAt) return
505+
if (dismissMode == TrayWindowDismissMode.AUTO) {
506+
// Auto dismiss on focus loss
507+
requestHideExplicit()
503508
}
504-
requestHide()
505509
}
506510
}
507511

508-
val macWatcher = if (getOperatingSystem() == MACOS) {
512+
// Outside click watchers: start them only in AUTO mode
513+
val macWatcher = if (dismissMode == TrayWindowDismissMode.AUTO && getOperatingSystem() == MACOS) {
509514
MacOutsideClickWatcher(
510515
windowSupplier = { window },
511-
onOutsideClick = { invokeLater { requestHide() } }
516+
onOutsideClick = { invokeLater { requestHideExplicit() } }
512517
).also { it.start() }
513518
} else null
514519

515-
val linuxWatcher = if (getOperatingSystem() == OperatingSystem.LINUX) {
520+
val linuxWatcher = if (dismissMode == TrayWindowDismissMode.AUTO && getOperatingSystem() == OperatingSystem.LINUX) {
516521
LinuxOutsideClickWatcher(
517522
windowSupplier = { window },
518-
onOutsideClick = { invokeLater { requestHide() } }
523+
onOutsideClick = { invokeLater { requestHideExplicit() } }
519524
).also { it.start() }
520525
} else null
521526

@@ -536,4 +541,4 @@ fun ApplicationScope.TrayApp(
536541
.alpha(alpha)
537542
) { content() }
538543
}
539-
}
544+
}
Lines changed: 36 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.kdroid.composetray.tray.api
22

3-
import androidx.compose.runtime.*
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.ExperimentalComposeApi
5+
import androidx.compose.runtime.remember
46
import androidx.compose.ui.unit.DpSize
57
import androidx.compose.ui.unit.dp
68
import kotlinx.coroutines.flow.MutableStateFlow
@@ -9,82 +11,71 @@ import kotlinx.coroutines.flow.asStateFlow
911

1012
/**
1113
* State holder for TrayApp that provides programmatic control over the tray window
12-
* and observability of its state changes.
14+
* and observability of its state changes, including window dismiss behavior.
1315
*/
1416
@ExperimentalTrayAppApi
1517
class TrayAppState(
1618
initialWindowSize: DpSize = DpSize(300.dp, 200.dp),
17-
initiallyVisible: Boolean = false
19+
initiallyVisible: Boolean = false,
20+
initialDismissMode: TrayWindowDismissMode = TrayWindowDismissMode.AUTO,
1821
) {
1922
// Internal mutable state
2023
private val _isVisible = MutableStateFlow(initiallyVisible)
2124
private val _windowSize = MutableStateFlow(initialWindowSize)
22-
25+
private val _dismissMode = MutableStateFlow(initialDismissMode)
26+
2327
// Public observable state flows
2428
val isVisible: StateFlow<Boolean> = _isVisible.asStateFlow()
2529
val windowSize: StateFlow<DpSize> = _windowSize.asStateFlow()
26-
27-
// Callbacks for visibility changes
30+
val dismissMode: StateFlow<TrayWindowDismissMode> = _dismissMode.asStateFlow()
31+
32+
// Callback for visibility changes
2833
private var onVisibilityChanged: ((Boolean) -> Unit)? = null
29-
30-
/**
31-
* Shows the tray window
32-
*/
34+
35+
/** Shows the tray window */
3336
fun show() {
3437
if (!_isVisible.value) {
3538
_isVisible.value = true
3639
onVisibilityChanged?.invoke(true)
3740
}
3841
}
39-
40-
/**
41-
* Hides the tray window
42-
*/
42+
43+
/** Hides the tray window (explicit hide, works in any dismiss mode) */
4344
fun hide() {
4445
if (_isVisible.value) {
4546
_isVisible.value = false
4647
onVisibilityChanged?.invoke(false)
4748
}
4849
}
49-
50-
/**
51-
* Toggles the visibility of the tray window
52-
*/
50+
51+
/** Toggles the visibility of the tray window */
5352
fun toggle() {
5453
val newVisibility = !_isVisible.value
5554
_isVisible.value = newVisibility
5655
onVisibilityChanged?.invoke(newVisibility)
5756
}
58-
59-
/**
60-
* Updates the window size
61-
* @param size The new window size
62-
*/
57+
58+
/** Updates the window size */
6359
fun setWindowSize(size: DpSize) {
6460
_windowSize.value = size
6561
}
66-
67-
/**
68-
* Updates the window size
69-
* @param width The new window width
70-
* @param height The new window height
71-
*/
62+
63+
/** Updates the window size */
7264
fun setWindowSize(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
7365
_windowSize.value = DpSize(width, height)
7466
}
75-
76-
/**
77-
* Sets a callback to be invoked when visibility changes
78-
* @param callback The callback to invoke with the new visibility state
79-
*/
67+
68+
/** Updates the dismiss mode (AUTO or MANUAL) */
69+
fun setDismissMode(mode: TrayWindowDismissMode) {
70+
_dismissMode.value = mode
71+
}
72+
73+
/** Sets a callback to be invoked when visibility changes */
8074
fun onVisibilityChanged(callback: (Boolean) -> Unit) {
8175
onVisibilityChanged = callback
8276
}
83-
84-
/**
85-
* Internal method to update visibility from within TrayApp
86-
* (e.g., when user clicks outside or closes the window)
87-
*/
77+
78+
/** Internal method to update visibility from TrayApp internals */
8879
internal fun updateVisibility(visible: Boolean) {
8980
if (_isVisible.value != visible) {
9081
_isVisible.value = visible
@@ -93,21 +84,19 @@ class TrayAppState(
9384
}
9485
}
9586

96-
/**
97-
* Creates and remembers a TrayAppState instance
98-
* @param initialWindowSize The initial window size
99-
* @param initiallyVisible Whether the window should be initially visible
100-
*/
87+
/** Creates and remembers a TrayAppState instance */
10188
@ExperimentalTrayAppApi
10289
@Composable
10390
fun rememberTrayAppState(
10491
initialWindowSize: DpSize = DpSize(300.dp, 200.dp),
105-
initiallyVisible: Boolean = false
92+
initiallyVisible: Boolean = false,
93+
initialDismissMode: TrayWindowDismissMode = TrayWindowDismissMode.AUTO,
10694
): TrayAppState {
10795
return remember {
10896
TrayAppState(
10997
initialWindowSize = initialWindowSize,
110-
initiallyVisible = initiallyVisible
98+
initiallyVisible = initiallyVisible,
99+
initialDismissMode = initialDismissMode
111100
)
112101
}
113-
}
102+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.kdroid.composetray.tray.api
2+
3+
/**
4+
* Defines how the tray window should be dismissed (hidden)
5+
*/
6+
@ExperimentalTrayAppApi
7+
enum class TrayWindowDismissMode {
8+
/**
9+
* The window automatically hides when it loses focus or when clicking outside.
10+
* This is the traditional behavior for tray popup windows.
11+
*/
12+
AUTO,
13+
14+
/**
15+
* The window remains visible until explicitly hidden via TrayAppState.hide()
16+
* or by clicking the tray icon again.
17+
* Focus loss and outside clicks do not dismiss the window.
18+
*/
19+
MANUAL
20+
}

0 commit comments

Comments
 (0)