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