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 @@ -243,12 +243,21 @@ internal class WindowsTrayManager(
val xRef = IntByReference()
val yRef = IntByReference()
val precise = WindowsNativeTrayLibrary.tray_get_notification_icons_position(xRef, yRef) != 0
log("tray_get_notification_icons_position: precise=$precise, rawX=${xRef.value}, rawY=${yRef.value}")
if (precise) {
// Native coordinates are in physical pixels, but AWT uses logical pixels.
// Convert physical to logical by dividing by the DPI scale factor.
val scale = getDpiScale(xRef.value, yRef.value)
val logicalX = (xRef.value / scale).toInt()
val logicalY = (yRef.value / scale).toInt()

val screen = java.awt.Toolkit.getDefaultToolkit().screenSize
log("DPI scale=$scale, logicalX=$logicalX, logicalY=$logicalY, screenW=${screen.width}, screenH=${screen.height}")
val corner = com.kdroid.composetray.utils.convertPositionToCorner(
xRef.value, yRef.value, screen.width, screen.height
logicalX, logicalY, screen.width, screen.height
)
TrayClickTracker.setClickPosition(instanceId, xRef.value, yRef.value, corner)
log("Detected corner: $corner")
TrayClickTracker.setClickPosition(instanceId, logicalX, logicalY, corner)
true
} else {
false
Expand All @@ -264,6 +273,57 @@ internal class WindowsTrayManager(
}
}

/**
* Public method to force a fresh capture of the tray icon position.
* Called when Windows may have reorganized icons after creation.
*/
fun refreshPosition() {
log("Refreshing tray position...")
safeGetTrayPosition(instanceId)
}

/**
* Gets the DPI scale factor for the screen containing the given physical coordinates.
* Returns 1.0 for 100% scaling, 1.25 for 125%, 1.5 for 150%, etc.
*
* @param physicalX X coordinate in physical pixels (optional, uses primary screen if not provided)
* @param physicalY Y coordinate in physical pixels (optional, uses primary screen if not provided)
*/
private fun getDpiScale(physicalX: Int? = null, physicalY: Int? = null): Double {
return try {
val ge = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()

// If coordinates provided, try to find the screen containing those coordinates
if (physicalX != null && physicalY != null) {
for (gd in ge.screenDevices) {
val config = gd.defaultConfiguration
val scale = config.defaultTransform.scaleX
// Convert physical bounds to check containment
val bounds = config.bounds
val physBounds = java.awt.Rectangle(
(bounds.x * scale).toInt(),
(bounds.y * scale).toInt(),
(bounds.width * scale).toInt(),
(bounds.height * scale).toInt()
)
if (physBounds.contains(physicalX, physicalY)) {
return scale
}
}
}

// Fallback to primary screen
ge.defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX
} catch (_: Throwable) {
// Fallback: use screen resolution (96 DPI = 100%)
try {
java.awt.Toolkit.getDefaultToolkit().screenResolution / 96.0
} catch (_: Throwable) {
1.0
}
}
}

private fun processUpdateQueue() {
val update = synchronized(updateQueueLock) {
if (updateQueue.isNotEmpty()) {
Expand Down
40 changes: 36 additions & 4 deletions src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import com.kdroid.composetray.lib.linux.LinuxOutsideClickWatcher
import com.kdroid.composetray.utils.debugln
import com.kdroid.composetray.lib.mac.MacOSWindowManager
import com.kdroid.composetray.lib.mac.MacOutsideClickWatcher
import com.kdroid.composetray.lib.mac.MacTrayLoader
import com.kdroid.composetray.lib.windows.WindowsOutsideClickWatcher
import com.kdroid.composetray.tray.impl.WindowsTrayInitializer
import com.kdroid.composetray.menu.api.TrayMenuBuilder
import com.kdroid.composetray.utils.*
import io.github.kdroidfilter.platformtools.LinuxDesktopEnvironment
Expand Down Expand Up @@ -514,7 +516,12 @@ private fun ApplicationScope.TrayAppImplOriginal(
// Store window reference for macOS Space detection
var windowRef by remember { mutableStateOf<java.awt.Window?>(null) }

val dialogState = rememberDialogState(size = currentWindowSize)
// Position off-screen initially to prevent flash at wrong position.
// The LaunchedEffect will set the correct position before showing the window.
val dialogState = rememberDialogState(
position = WindowPosition((-10000).dp, (-10000).dp),
size = currentWindowSize
)
LaunchedEffect(currentWindowSize) { dialogState.size = currentWindowSize }

// Visibility controller for exit-finish observation; content will NOT be disposed.
Expand Down Expand Up @@ -556,10 +563,12 @@ private fun ApplicationScope.TrayAppImplOriginal(
windowRef!!.requestFocusInWindow()
}
}
return@internalPrimaryAction
} else {
requestHideExplicit()
}
} else {
requestHideExplicit()
}
requestHideExplicit()
} else {
if (now - lastHiddenAt >= minHiddenDurationMs) {
if (getOperatingSystem() == WINDOWS && (now - lastFocusLostAt) < 300) {
Expand Down Expand Up @@ -591,27 +600,47 @@ private fun ApplicationScope.TrayAppImplOriginal(
pendingPosition = null

val position = if (preComputed != null && preComputed !is WindowPosition.PlatformDefault) {
debugln { "[TrayApp] Using preComputed position: $preComputed" }
preComputed
} else {
// Fallback: poll for position (e.g. initiallyVisible or programmatic show)
delay(250)
// Wait for Windows to finish reorganizing tray icons after adding a new one.
// Windows moves icons around after creation, so we need to wait and re-poll.
debugln { "[TrayApp] No preComputed position, waiting for tray to stabilize..." }
delay(400) // Give Windows time to reorganize tray icons

val widthPx = currentWindowSize.width.value.toInt()
val heightPx = currentWindowSize.height.value.toInt()

// On Windows, force a fresh position capture via the native API
if (getOperatingSystem() == WINDOWS) {
debugln { "[TrayApp] Re-capturing tray position from native API..." }
WindowsTrayInitializer.refreshPosition(tray.instanceKey())
delay(50) // Let the position update propagate
}

var pos: WindowPosition = WindowPosition.PlatformDefault
val deadline = System.currentTimeMillis() + 3000
while (pos is WindowPosition.PlatformDefault && System.currentTimeMillis() < deadline) {
pos = getTrayWindowPositionForInstance(
tray.instanceKey(), widthPx, heightPx, horizontalOffset, verticalOffset
)
debugln { "[TrayApp] Polled position: $pos" }
if (pos is WindowPosition.PlatformDefault) delay(250)
}
pos
}
debugln { "[TrayApp] Setting dialogState.position = $position" }
dialogState.position = position

// Wait for Compose to apply the position before showing the window
// This prevents the window from flashing at the wrong position
delay(150) // Give Compose time to recompose with new position

if (getOperatingSystem() == WINDOWS) {
autoHideEnabledAt = System.currentTimeMillis() + 1000
}
debugln { "[TrayApp] Now showing window" }
shouldShowWindow = true
lastShownAt = System.currentTimeMillis()
}
Expand Down Expand Up @@ -665,12 +694,15 @@ private fun ApplicationScope.TrayAppImplOriginal(
try { window.name = WindowVisibilityMonitor.TRAY_DIALOG_NAME } catch (_: Throwable) {}
runCatching { WindowVisibilityMonitor.recompute() }

debugln { "[TrayApp] Window shown at native position: x=${window.x}, y=${window.y}, dialogState.position=${dialogState.position}" }

invokeLater {
// Move the popup to the current Space before bringing it to front (macOS)
if (getOperatingSystem() == MACOS) {
runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() }
runCatching { MacOSWindowManager().setMoveToActiveSpace(window) }
}
debugln { "[TrayApp] After invokeLater: window at x=${window.x}, y=${window.y}" }
runCatching {
window.toFront()
window.requestFocus()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ object WindowsTrayInitializer {
trayManagers.remove(id)?.stopTray()
}

/**
* Force a fresh capture of the tray icon position.
* This is useful when Windows reorganizes icons after creation.
*/
@Synchronized
fun refreshPosition(id: String) {
trayManagers[id]?.refreshPosition()
}

// Backward-compatible API for existing callers (single default tray)
fun initialize(iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null) =
initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,10 @@ fun getTrayWindowPositionForInstance(
return when (os) {
OperatingSystem.WINDOWS -> {
val pos = TrayClickTracker.getLastClickPosition(instanceId)
?: return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
if (pos == null) {
debugln { "[TrayPosition] getTrayWindowPositionForInstance: no position for $instanceId, using fallback" }
return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
}
calculateWindowPositionFromClick(
pos.x, pos.y, pos.position,
windowWidth, windowHeight,
Expand Down Expand Up @@ -385,16 +388,19 @@ private fun calculateWindowPositionFromClick(
val isRight = trayPosition == TrayPosition.TOP_RIGHT || trayPosition == TrayPosition.BOTTOM_RIGHT

val sb = getScreenBoundsAt(clickX, clickY)
debugln { "[TrayPosition] calculateWindowPositionFromClick: clickX=$clickX, clickY=$clickY, trayPos=$trayPosition, winW=$windowWidth, winH=$windowHeight, screenBounds=$sb" }

return if (os == OperatingSystem.WINDOWS) {
var x = clickX - (windowWidth / 2)
var y = if (isTop) clickY else clickY - windowHeight
debugln { "[TrayPosition] Windows: isTop=$isTop, initial x=$x, y=$y" }

x += horizontalOffset
y += verticalOffset

if (x < sb.x) x = sb.x else if (x + windowWidth > sb.x + sb.width) x = sb.x + sb.width - windowWidth
if (y < sb.y) y = sb.y else if (y + windowHeight > sb.y + sb.height) y = sb.y + sb.height - windowHeight
debugln { "[TrayPosition] Windows: final x=$x, y=$y" }
WindowPosition(x = x.dp, y = y.dp)
} else {
val panelGuessPx = 28
Expand Down
Loading