From 5dafc60dd23ba9001ac537e5d8f312e1a27023a9 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Fri, 6 Feb 2026 12:08:50 +0200 Subject: [PATCH] Fix Windows tray window position on first display Windows reorganizes tray icons after creation, causing the initial position capture to be incorrect. The window would appear offset from the actual tray icon location. Changes: - Convert native physical coordinates to logical coordinates for DPI scaling - Wait for Windows to stabilize tray icons before capturing position - Add refreshPosition() to re-capture position just before showing window - Initialize dialog position off-screen to prevent flash at wrong location - Fix TrayApp.kt compilation error with return@label inside invokeLater --- .../lib/windows/WindowsTrayManager.kt | 64 ++++++++++++++++++- .../kdroid/composetray/tray/api/TrayApp.kt | 40 ++++++++++-- .../tray/impl/WindowsTrayInitializer.kt | 9 +++ .../kdroid/composetray/utils/TrayPosition.kt | 8 ++- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt index 3483956..108361b 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt @@ -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 @@ -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()) { diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt index a272fb4..fa50d73 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt @@ -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 @@ -514,7 +516,12 @@ private fun ApplicationScope.TrayAppImplOriginal( // Store window reference for macOS Space detection var windowRef by remember { mutableStateOf(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. @@ -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) { @@ -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() } @@ -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() diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt index e9af39c..a257a09 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt @@ -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) diff --git a/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt b/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt index 6df9bff..5e15ea5 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt @@ -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, @@ -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