Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ object MacTrayInitializer {
return runCatching { MacNativeBridge.nativeGetStatusItemRegionFor(handle) }.getOrNull()
}

/**
* Drains one pending AppKit event. TrayApp does this via the Compose main
* dispatcher; tests that query [statusItemPositionFor] off a Compose runtime
* must pump so the status item can lay out.
*/
fun pumpEventLoop() {
if (!MacNativeBridge.isLoaded) return
runCatching { MacNativeBridge.nativeLoopTray(0) }
}

@Synchronized
fun initialize(
id: String,
Expand Down
108 changes: 49 additions & 59 deletions src/native/macos/tray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,42 @@ public func tray_is_menu_dark() -> Int32 {

// MARK: - Status‑item geometry (exported to C)

/// Laid-out status-item rect in screen coordinates, or `nil` if AppKit has not
/// assigned the item a menu-bar slot yet. A brand-new `NSStatusItem` is parked
/// at the menu bar's left edge (relative X ≈ 0); reporting that as precise
/// lands the TrayApp popup at the top-left of the screen.
private struct StatusItemGeometry {
let rect: NSRect
let screen: NSScreen
}

private func laidOutGeometry(for ctx: TrayContext) -> StatusItemGeometry? {
guard
let button = ctx.statusItem.button,
let window = button.window,
let screen = window.screen
else { return nil }

var rect = button.convert(button.bounds, to: nil as NSView?)
rect = window.convertToScreen(rect)
guard rect.width >= 1, rect.height >= 1 else { return nil }

// Menu extras always live on the right of the menu bar. Relative X is
// measured against the screen that owns the item, so a left-arranged
// external display (origin.x < 0) still reports a large relative X.
let relativeX = rect.minX - screen.frame.minX
guard relativeX >= 8 else { return nil }
return StatusItemGeometry(rect: rect, screen: screen)
}

private func writeFlippedPosition(_ geometry: StatusItemGeometry,
x: UnsafeMutablePointer<Int32>?,
y: UnsafeMutablePointer<Int32>?) {
x?.pointee = Int32(lround(geometry.rect.midX))
let primaryHeight = NSScreen.screens.first?.frame.height ?? geometry.screen.frame.height
y?.pointee = Int32(primaryHeight - geometry.rect.maxY)
}

// Returns 1 if the coordinate is precise, 0 if we had to use a fallback.
@_cdecl("tray_get_status_item_position")
public func tray_get_status_item_position(
Expand All @@ -432,48 +468,24 @@ public func tray_get_status_item_position(
) -> Int32
{
let ctx = trayInstance.flatMap { contexts[$0] } ?? contexts.values.first
guard
let button = ctx?.statusItem.button,
let window = button.window,
let screen = window.screen
else {
guard let ctx, let geometry = laidOutGeometry(for: ctx) else {
x?.pointee = 0
y?.pointee = 0
return 0 // unreliable coordinates
}

// Button frame in screen space (origin at bottom-left)
var rect = button.convert(button.bounds, to: nil as NSView?)
rect = window.convertToScreen(rect)

// -- X ---------------------------------------------------------------
// Horizontal center of the icon (ideal for centered placement)
x?.pointee = Int32(lround(rect.midX))

// -- Y ---------------------------------------------------------------
// Convert macOS bottom-origin to AWT top-origin using primary screen height.
// This produces correct global coordinates for all screens (including external).
let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height
let flippedY = Int32(primaryHeight - rect.maxY)
y?.pointee = flippedY

writeFlippedPosition(geometry, x: x, y: y)
return 1 // precise coordinates
}

/// Returns "top-left" or "top-right" (menu-bar always at top).
@_cdecl("tray_get_status_item_region")
public func tray_get_status_item_region() -> UnsafeMutablePointer<CChar>? {
let ctx = trayInstance.flatMap { contexts[$0] } ?? contexts.values.first
guard let button = ctx?.statusItem.button,
let screen = button.window?.screen else {
return strdup("top-right") // default value
guard let ctx, let geometry = laidOutGeometry(for: ctx) else {
return strdup("top-right") // default until the item is laid out
}

let rect = button.window!.convertToScreen(
button.convert(button.bounds, to: nil as NSView?)
)
let midX = screen.frame.midX
let region = rect.minX < midX ? "top-left" : "top-right"
let midX = geometry.screen.frame.midX
let region = geometry.rect.minX < midX ? "top-left" : "top-right"
return strdup(region) // to be freed on JVM/JNI side
}

Expand All @@ -484,48 +496,26 @@ public func tray_get_status_item_position_for(
_ x: UnsafeMutablePointer<Int32>?,
_ y: UnsafeMutablePointer<Int32>?
) -> Int32 {
guard let tray = tray, let ctx = contexts[tray] else {
guard let tray = tray, let ctx = contexts[tray],
let geometry = laidOutGeometry(for: ctx) else {
x?.pointee = 0
y?.pointee = 0
return 0
}
guard
let button = ctx.statusItem.button,
let window = button.window,
let screen = window.screen
else {
x?.pointee = 0
y?.pointee = 0
return 0
}

var rect = button.convert(button.bounds, to: nil as NSView?)
rect = window.convertToScreen(rect)

x?.pointee = Int32(lround(rect.midX))
// Convert macOS bottom-origin to AWT top-origin using primary screen height.
let primaryHeight = NSScreen.screens.first?.frame.height ?? screen.frame.height
let flippedY = Int32(primaryHeight - rect.maxY)
y?.pointee = flippedY
writeFlippedPosition(geometry, x: x, y: y)
return 1
}

@_cdecl("tray_get_status_item_region_for")
public func tray_get_status_item_region_for(
_ tray: UnsafeMutableRawPointer?
) -> UnsafeMutablePointer<CChar>? {
guard let tray = tray, let ctx = contexts[tray] else {
return strdup("top-right")
}
guard let button = ctx.statusItem.button,
let screen = button.window?.screen else {
guard let tray = tray, let ctx = contexts[tray],
let geometry = laidOutGeometry(for: ctx) else {
return strdup("top-right")
}
let rect = button.window!.convertToScreen(
button.convert(button.bounds, to: nil as NSView?)
)
let midX = screen.frame.midX
let region = rect.minX < midX ? "top-left" : "top-right"
let midX = geometry.screen.frame.midX
let region = geometry.rect.minX < midX ? "top-left" : "top-right"
return strdup(region)
}

Expand Down
4 changes: 4 additions & 0 deletions tray-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ kotlin {
api(libs.nucleus.application)
implementation(libs.nucleus.decorated.window.tao)
}
jvmTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package dev.nucleusframework.composenativetray.trayapp

import androidx.compose.ui.window.WindowPosition
import kotlinx.coroutines.delay
import kotlin.math.abs

/**
* AppKit parks a brand-new NSStatusItem at the menu-bar's left edge until the
* status bar assigns it a slot. Menu extras always live on the right, so a
* relative X below this threshold means "not laid out yet" — treating it as a
* real anchor puts the popup at the top-left of the screen.
*/
internal const val MAC_STATUS_ITEM_MIN_RELATIVE_X_PX = 24

/**
* Waits until [read] yields a usable, stable tray-anchored window position.
*
* The historical poller returned the first non-[WindowPosition.PlatformDefault]
* value. On macOS that is often `Absolute(0, 0)` / the work-area origin: the
* status item exists but has not been assigned a menu-bar slot yet, so any
* `initiallyVisible` TrayApp opens at the top-left on most launches.
*/
internal suspend fun awaitAnchoredWindowPosition(
timeoutMs: Long = 3_000L,
pollDelayMs: Long = 50L,
minStableReads: Int = 2,
isUsable: (WindowPosition) -> Boolean = { isUsableAnchorPosition(it) },
delayMs: suspend (Long) -> Unit = { delay(it) },
read: suspend () -> WindowPosition,
): WindowPosition {
require(minStableReads >= 1)
val deadline = System.currentTimeMillis() + timeoutMs
var lastUsable: WindowPosition? = null
var stableCount = 0
var lastRead: WindowPosition = WindowPosition.PlatformDefault

while (System.currentTimeMillis() < deadline) {
val pos = read()
lastRead = pos
if (isUsable(pos)) {
if (lastUsable != null && sameAbsolute(lastUsable, pos)) {
stableCount++
if (stableCount + 1 >= minStableReads) return pos
} else {
lastUsable = pos
stableCount = 0
if (minStableReads <= 1) return pos
}
} else {
lastUsable = null
stableCount = 0
}
delayMs(pollDelayMs)
}
return lastUsable ?: lastRead
}

internal fun isLaidOutMacStatusItem(
x: Int,
@Suppress("UNUSED_PARAMETER") y: Int,
screen: ScreenRect,
): Boolean = (x - screen.x) >= MAC_STATUS_ITEM_MIN_RELATIVE_X_PX

internal fun isUsableAnchorPosition(
pos: WindowPosition,
workArea: ScreenRect? = null,
): Boolean {
if (pos !is WindowPosition.Absolute) return false
if (pos.x.value == 0f && pos.y.value == 0f) return false
return workArea == null || abs(pos.x.value - workArea.x) >= 1.5f
}

private fun sameAbsolute(
a: WindowPosition,
b: WindowPosition,
): Boolean {
if (a !is WindowPosition.Absolute || b !is WindowPosition.Absolute) return a == b
return abs(a.x.value - b.x.value) < 1f && abs(a.y.value - b.y.value) < 1f
}
Original file line number Diff line number Diff line change
Expand Up @@ -623,18 +623,21 @@ private fun TrayAppImplPanel(
if (!popupOnScreen) {
val preComputed = pendingPosition
pendingPosition = null
val workArea = TrayScreenGeometry.workAreaLogical()
val usable = { pos: WindowPosition -> isUsableAnchorPosition(pos, workArea) }

val position =
if (preComputed != null && preComputed !is WindowPosition.PlatformDefault) {
if (preComputed != null && usable(preComputed)) {
debugln { "[TrayApp] Using preComputed position: $preComputed" }
preComputed
} else {
// Fallback: poll for position (e.g. visibleOnStart or programmatic show).
// visibleOnStart / programmatic show: the tray icon may not be laid
// out yet. On macOS NSStatusItem reports a left-edge rect until the
// status bar assigns a slot — latching that puts the popup at (0,0).
debugln { "[TrayApp] No preComputed position, waiting for tray to stabilize..." }
val widthPx = currentWindowSize.width.value.toInt()
val heightPx = currentWindowSize.height.value.toInt()

var pos: WindowPosition = WindowPosition.PlatformDefault
if (Platform.Current == Platform.Windows) {
// Windows moves tray icons around after creation, so wait for the
// shell to settle, refresh the cached icon rect, then re-poll.
Expand All @@ -643,18 +646,20 @@ private fun TrayAppImplPanel(
WindowsTrayInitializer.refreshPosition(instanceKey)
delay(50)
}
// macOS positions precisely up front (status item rect), so it skips
// the stabilization wait and resolves on the first poll below.
val deadline = System.currentTimeMillis() + 3000
while (pos is WindowPosition.PlatformDefault && System.currentTimeMillis() < deadline) {
pos =
getTrayWindowPositionForInstance(
instanceKey, widthPx, heightPx, horizontalOffset, verticalOffset,
)
debugln { "[TrayApp] Polled position: $pos" }
if (pos is WindowPosition.PlatformDefault) delay(250)
}
if (pos is WindowPosition.PlatformDefault) {
var pos =
awaitAnchoredWindowPosition(isUsable = usable) {
val next =
getTrayWindowPositionForInstance(
instanceKey,
widthPx,
heightPx,
horizontalOffset,
verticalOffset,
)
debugln { "[TrayApp] Polled position: $next" }
next
}
if (!usable(pos)) {
// Tray never became ready within the deadline — fall back
// to the corner heuristic rather than showing at (0,0).
pos = getTrayWindowPosition(widthPx, heightPx, horizontalOffset, verticalOffset)
Expand Down Expand Up @@ -691,7 +696,7 @@ private fun TrayAppImplPanel(
val w = currentWindowSize.width.value.toInt()
val h = currentWindowSize.height.value.toInt()
val pos = getTrayWindowPositionForInstance(instanceKey, w, h, horizontalOffset, verticalOffset)
if (pos is WindowPosition.Absolute) popupScreenPos = pos
if (pos is WindowPosition.Absolute && isUsableAnchorPosition(pos)) popupScreenPos = pos
}
}

Expand Down Expand Up @@ -852,15 +857,29 @@ private fun NucleusApplicationScope.TrayAppImplWindow(
if (!shouldShowWindow) {
val preComputed = pendingPosition
pendingPosition = null
val widthPx = currentWindowSize.width.value.toInt()
val heightPx = currentWindowSize.height.value.toInt()
val workArea = TrayScreenGeometry.workAreaLogical()
val usable = { pos: WindowPosition -> isUsableAnchorPosition(pos, workArea) }
val position =
preComputed
?: getTrayWindowPositionForInstance(
instanceKey,
currentWindowSize.width.value.toInt(),
currentWindowSize.height.value.toInt(),
horizontalOffset,
verticalOffset,
)
if (preComputed != null && usable(preComputed)) {
preComputed
} else {
var pos =
awaitAnchoredWindowPosition(isUsable = usable) {
getTrayWindowPositionForInstance(
instanceKey,
widthPx,
heightPx,
horizontalOffset,
verticalOffset,
)
}
if (!usable(pos)) {
pos = getTrayWindowPosition(widthPx, heightPx, horizontalOffset, verticalOffset)
}
pos
}
windowState.position = position
delay(30)
shouldShowWindow = true
Expand Down
Loading
Loading