Compose Native Tray Documentation

repository·master·Indexed 18 days ago

https://github.com/nucleusframework/composenativetray

A Kotlin library for creating cross-platform system tray applications for Linux, Windows, and macOS. It provides a reactive DSL for tray menus, fixes HDPI and appearance issues in Compose for Desktop, and includes a TrayApp component for creating transparent popup windows and mini-dashboards. Features include single instance management, dark mode detection, and precise window positioning relative to the system tray.

Tokens
6.1K
Snippets
17
Records
20
Agent score
64%

What's inside Compose Native Tray

  1. How the macOS TrayApp panel works (NSPanel)

    master

    The macOS implementation of the transparent tray popup uses an NSPanel to mirror the Windows UX.

    Key Technical Details:

    • Window Style: Uses .nonactivatingPanel style with level = .statusBar to ensure it stays topmost above normal applications without activating the app when interacting with it.
    • Transparency: Uses a transparent CAMetalLayer via TaoPopupSceneLayer for rendering.
    • Input Handling:
      • Outside Clicks: Uses a combination of NSEvent.addGlobalMonitorForEvents (for clicks outside the app) and a local monitor (for clicks inside) to handle dismissal.
      • Keyboard: Overrides canBecomeKeyWindow = true to allow the panel to take key focus. Keyboard events (keyDown/keyUp) are forwarded using the dispatchNativeKeyEvent wire format.
    • Rendering: Uses the Metal render path (Skia DirectContext per host) rather than the EGL path used on other platforms.
  2. Create a fully reactive system menu

    master

    The library supports Compose recomposition for all aspects of the system menu. You can use standard Compose state (e.g., mutableStateOf) to dynamically change icon, labels, item visibility, and item states (like isEnabled or checked) without manually recreating the menu. This allows for seamless UI updates when application state changes.

    // Example: Fully reactive menu
    application {
      var darkMode by remember { mutableStateOf(false) }
      var showAdvancedOptions by remember { mutableStateOf(false) }
      var notificationsEnabled by remember { mutableStateOf(true) }
      var isConfigAvailable by remember { mutableStateOf(false) }
    
      Tray(
        icon = if (darkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
        tooltip = "My Application"
      ) {
        Item(
          label = if (darkMode) "Switch to Light Mode" else "Switch to Dark Mode",
          icon = if (darkMode) Icons.Default.LightMode else Icons.Default.DarkMode
        ) { 
          darkMode = !darkMode 
        }
    
        CheckableItem(
          label = "Notifications",
          checked = notificationsEnabled,
          onCheckedChange = { notificationsEnabled = it }
        )
    
        if (showAdvancedOptions) {
          Divider()
          SubMenu(label = "Advanced Options") {
            Item(
              label = "Configuration", 
              isEnabled = isConfigAvailable
            ) { /* action */ }
            
            Item(label = "Check Configuration Availability") { 
              isConfigAvailable = true 
            }
          }
        }
    
        Divider()
    
        Item(label = if (showAdvancedOptions) "Hide Advanced Options" else "Show Advanced Options") {
          showAdvancedOptions = !showAdvancedOptions
        }
      }
    }
  3. How the Linux TrayApp panel works (X11/XWayland)

    master

    On Linux, the transparent tray popup is implemented as a raw X11 override-redirect ARGB32 window rather than a GTK window. This allows the panel to function as an independent X client that works even if the main application is a native Wayland client (via XWayland).

    Key Technical Details:

    • Visuals: It queries EGL for alpha-capable desktop-GL configurations (EGL_NATIVE_VISUAL_ID) to ensure transparency is preserved.
    • Threading: Uses two X connections: one for commands (owned by the Tao main thread) and one for events (owned by a dedicated per-panel thread).
    • Input:
      • Mouse: Uses XI2 raw ButtonPress on the root window to detect outside clicks (similar to Windows WH_MOUSE_LL).
      • Keyboard: Sends raw X keysyms as vkCode and Unicode codePoints. A translation layer (linuxNativeKeyToAwt) converts these to AWT VK codes.
    • Focus: Uses XSetInputFocus(RevertToParent) on click to allow the panel to take keyboard focus without a full window grab.
  4. Understand TrayApp platform routing and behavior

    master

    The TrayApp API (found in dev.nucleusframework.composenativetray.tray.api.TrayApp) routes its implementation based on the host platform to provide a consistent user experience.

    • Windows: Uses TrayAppImplPanel, which renders into a TaoStandalonePopup. This is a top-level, ownerless, topmost, non-activating native panel with per-pixel transparency. It does not appear in the taskbar, Alt-Tab, or task view. It supports slide and fade animations.
    • macOS: Uses TrayAppImplPanel (via NSPanel). It is an ownerless, non-activating panel with .statusBar level (topmost), transparent background, and supports keyboard focus without activating the main application.
    • Linux: Uses TrayAppImplPanel (via a raw X11 override-redirect window) when isTaoStandalonePopupAvailable() is true. This works on GNOME Wayland via XWayland. If a native Wayland/layer-shell environment is required but unavailable, it falls back to TrayAppImplWindow, which is an opaque, focus-based DecoratedWindow.

    Note on Linux Fallback: If the environment is X-server-less (pure Wayland without XWayland support), the system falls back to the opaque TrayAppImplWindow implementation.

  5. Use TrayApp for a tray icon and popup window

    master

    The TrayApp component provides a system tray/menu-bar icon and a popup window for quick actions. It is ideal for mini-dashboards or control centers.

    Key features:

    • rememberTrayAppState: Manages the state of the popup window, including size and visibility.
    • TrayApp parameters:
      • state: The TrayAppState instance.
      • icon: The icon to display in the tray (required).
      • tooltip: The tooltip text (required).
      • transparent / undecorated: Visual styles (defaults to true).
      • menu: A lambda to build a standard system menu.
    • Dismiss Modes:
      • TrayWindowDismissMode.AUTO (default): Closes when clicking outside or losing focus.
      • TrayWindowDismissMode.MANUAL: The window stays open until you explicitly call hide() or toggle().
    application {
        val trayAppState = rememberTrayAppState(
            initialWindowSize = DpSize(300.dp, 420.dp),
            initiallyVisible = true
        )
    
        TrayApp(
            state = trayAppState,
            icon = Icons.Default.Dashboard,
            tooltip = "My Tray App",
            transparent = true,
            undecorated = true,
            menu = {
                Item("Toggle popup") { trayAppState.toggle() }
                Divider()
                Item("Quit") { exitApplication() }
            }
        ) {
            MaterialTheme {
                Text("Quick Settings")
                Button(onClick = { trayAppState.hide() }) { Text("Close") }
            }
        }
    }
  6. Quick Start: Create a minimal system tray

    master

    Use the application block and the Tray composable to create a basic system tray icon with a simple menu containing an item and a divider.

    application {
      Tray(
        icon = Icons.Default.Favorite,
        tooltip = "My Application"
      ) {
        Item(label = "Settings") {
          println("Settings opened")
        }
        
        Divider()
        
        Item(label = "Exit") {
          exitProcess(0)
        }
      }
    }
  7. Configure ProGuard/R8 for release builds

    master

    Because this library relies on reflection and JNA, you must add specific ProGuard/R8 rules to your release build to prevent the tray icon from rendering incorrectly (e.g., semi-transparent backgrounds or broken click actions).

    -keep class com.sun.jna.** { *; }
    -keep class dev.nucleusframework.composenativetray.** { *; }
  8. Detect dark mode for system tray icons

    master

    Use isMenuBarInDarkMode() to detect if the system tray/menu bar is in dark mode. This allows you to adjust the tint of your icons to ensure visibility.

    Platform behavior:

    • macOS: The menu bar color depends on the wallpaper, not the system theme.
    • Windows: Follows the system theme.
    • Linux: Varies by desktop environment (GNOME/KDE/etc.).
    val isMenuBarDark = isMenuBarInDarkMode()
    
    Tray(
      iconContent = {
        Icon(
          Icons.Default.Favorite,
          contentDescription = "",
          tint = if (isMenuBarDark) Color.White else Color.Black,
          modifier = Modifier.fillMaxSize()
        )
      },
      tooltip = "My Application"
    ) { /* menu */ }
  9. Define a Primary Action for the Tray Icon

    master

    The primaryAction parameter allows you to define a callback that triggers when the tray icon is clicked.

    Platform Behavior:

    • Windows/macOS: Triggered by a left-click.
    • Linux (KDE): Triggered by a single left-click.
    • Linux (GNOME): Triggered by a double left-click.
    Tray(
      icon = Icons.Default.Favorite,
      tooltip = "My Application",
      primaryAction = {
        println("Icon clicked!")
      }
    ) { /* menu */ }
  10. Manage single application instances

    master

    Use SingleInstanceManager to prevent multiple instances of your application from running. This is useful for restoring a minimized application via the tray icon instead of opening a new window.

    • isSingleInstance(...): Checks if an instance is already running. If it is, the onRestoreRequest handler is triggered.
    • onRestoreFileCreated: A handler used to pass data (like deep links) from a new instance attempt to the existing main instance by writing to a temporary file.
    • Configuration: Allows customizing the lockFilesDir and appIdentifier to control the scope of the single instance management.
    import dev.nucleusframework.core.runtime.SingleInstanceManager
    
    var isWindowVisible by remember { mutableStateOf(true) }
    
    // Check for single instance and restore window if found
    val isSingleInstance = SingleInstanceManager.isSingleInstance(
      onRestoreRequest = {
        isWindowVisible = true
      }
    )
    
    if (!isSingleInstance) {
      exitApplication()
      return@application
    }
    
    // Passing data between instances
    SingleInstanceManager.isSingleInstance(
        onRestoreFileCreated = {
            args.firstOrNull()?.let(::writeText)
        },
        onRestoreRequest = {
            val data = readText()
            // restore window with data
        }
    )
    
    // Custom configuration
    SingleInstanceManager.configuration = Configuration(
      lockFilesDir = Paths.get("path/to/your/app/data/dir/single_instance_manager"),
      appIdentifier = "app_id"
    )
  11. Use DrawableResource in Menu Items

    master

    You can pass DrawableResource directly to menu item icons (e.g., in Item or SubMenu).

    Best Practice: When using painterResource for menu items, declare the resource as a variable within the composable context before the Tray call to ensure correct usage.

    Tray(icon = Res.drawable.app_icon, tooltip = "App") {
      SubMenu(label = "With icons", icon = Res.drawable.gear) {
        Item(label = "Action 1", icon = Res.drawable.star) { /* ... */ }
      }
    }
    
    // Correct way to use painterResource in menu
    application {
      val advancedIcon = painterResource(Res.drawable.advanced)
      
      Tray(/* config */) {
        SubMenu(label = "Advanced", icon = advancedIcon) { /* items */ }
      }
    }