Skip to content

Commit 5dafc60

Browse files
committed
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
1 parent 18eef46 commit 5dafc60

4 files changed

Lines changed: 114 additions & 7 deletions

File tree

src/commonMain/kotlin/com/kdroid/composetray/lib/windows/WindowsTrayManager.kt

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,21 @@ internal class WindowsTrayManager(
243243
val xRef = IntByReference()
244244
val yRef = IntByReference()
245245
val precise = WindowsNativeTrayLibrary.tray_get_notification_icons_position(xRef, yRef) != 0
246+
log("tray_get_notification_icons_position: precise=$precise, rawX=${xRef.value}, rawY=${yRef.value}")
246247
if (precise) {
248+
// Native coordinates are in physical pixels, but AWT uses logical pixels.
249+
// Convert physical to logical by dividing by the DPI scale factor.
250+
val scale = getDpiScale(xRef.value, yRef.value)
251+
val logicalX = (xRef.value / scale).toInt()
252+
val logicalY = (yRef.value / scale).toInt()
253+
247254
val screen = java.awt.Toolkit.getDefaultToolkit().screenSize
255+
log("DPI scale=$scale, logicalX=$logicalX, logicalY=$logicalY, screenW=${screen.width}, screenH=${screen.height}")
248256
val corner = com.kdroid.composetray.utils.convertPositionToCorner(
249-
xRef.value, yRef.value, screen.width, screen.height
257+
logicalX, logicalY, screen.width, screen.height
250258
)
251-
TrayClickTracker.setClickPosition(instanceId, xRef.value, yRef.value, corner)
259+
log("Detected corner: $corner")
260+
TrayClickTracker.setClickPosition(instanceId, logicalX, logicalY, corner)
252261
true
253262
} else {
254263
false
@@ -264,6 +273,57 @@ internal class WindowsTrayManager(
264273
}
265274
}
266275

276+
/**
277+
* Public method to force a fresh capture of the tray icon position.
278+
* Called when Windows may have reorganized icons after creation.
279+
*/
280+
fun refreshPosition() {
281+
log("Refreshing tray position...")
282+
safeGetTrayPosition(instanceId)
283+
}
284+
285+
/**
286+
* Gets the DPI scale factor for the screen containing the given physical coordinates.
287+
* Returns 1.0 for 100% scaling, 1.25 for 125%, 1.5 for 150%, etc.
288+
*
289+
* @param physicalX X coordinate in physical pixels (optional, uses primary screen if not provided)
290+
* @param physicalY Y coordinate in physical pixels (optional, uses primary screen if not provided)
291+
*/
292+
private fun getDpiScale(physicalX: Int? = null, physicalY: Int? = null): Double {
293+
return try {
294+
val ge = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
295+
296+
// If coordinates provided, try to find the screen containing those coordinates
297+
if (physicalX != null && physicalY != null) {
298+
for (gd in ge.screenDevices) {
299+
val config = gd.defaultConfiguration
300+
val scale = config.defaultTransform.scaleX
301+
// Convert physical bounds to check containment
302+
val bounds = config.bounds
303+
val physBounds = java.awt.Rectangle(
304+
(bounds.x * scale).toInt(),
305+
(bounds.y * scale).toInt(),
306+
(bounds.width * scale).toInt(),
307+
(bounds.height * scale).toInt()
308+
)
309+
if (physBounds.contains(physicalX, physicalY)) {
310+
return scale
311+
}
312+
}
313+
}
314+
315+
// Fallback to primary screen
316+
ge.defaultScreenDevice.defaultConfiguration.defaultTransform.scaleX
317+
} catch (_: Throwable) {
318+
// Fallback: use screen resolution (96 DPI = 100%)
319+
try {
320+
java.awt.Toolkit.getDefaultToolkit().screenResolution / 96.0
321+
} catch (_: Throwable) {
322+
1.0
323+
}
324+
}
325+
}
326+
267327
private fun processUpdateQueue() {
268328
val update = synchronized(updateQueueLock) {
269329
if (updateQueue.isNotEmpty()) {

src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ import androidx.compose.ui.unit.DpSize
2323
import androidx.compose.ui.unit.dp
2424
import androidx.compose.ui.window.*
2525
import com.kdroid.composetray.lib.linux.LinuxOutsideClickWatcher
26+
import com.kdroid.composetray.utils.debugln
2627
import com.kdroid.composetray.lib.mac.MacOSWindowManager
2728
import com.kdroid.composetray.lib.mac.MacOutsideClickWatcher
2829
import com.kdroid.composetray.lib.mac.MacTrayLoader
2930
import com.kdroid.composetray.lib.windows.WindowsOutsideClickWatcher
31+
import com.kdroid.composetray.tray.impl.WindowsTrayInitializer
3032
import com.kdroid.composetray.menu.api.TrayMenuBuilder
3133
import com.kdroid.composetray.utils.*
3234
import io.github.kdroidfilter.platformtools.LinuxDesktopEnvironment
@@ -514,7 +516,12 @@ private fun ApplicationScope.TrayAppImplOriginal(
514516
// Store window reference for macOS Space detection
515517
var windowRef by remember { mutableStateOf<java.awt.Window?>(null) }
516518

517-
val dialogState = rememberDialogState(size = currentWindowSize)
519+
// Position off-screen initially to prevent flash at wrong position.
520+
// The LaunchedEffect will set the correct position before showing the window.
521+
val dialogState = rememberDialogState(
522+
position = WindowPosition((-10000).dp, (-10000).dp),
523+
size = currentWindowSize
524+
)
518525
LaunchedEffect(currentWindowSize) { dialogState.size = currentWindowSize }
519526

520527
// Visibility controller for exit-finish observation; content will NOT be disposed.
@@ -556,10 +563,12 @@ private fun ApplicationScope.TrayAppImplOriginal(
556563
windowRef!!.requestFocusInWindow()
557564
}
558565
}
559-
return@internalPrimaryAction
566+
} else {
567+
requestHideExplicit()
560568
}
569+
} else {
570+
requestHideExplicit()
561571
}
562-
requestHideExplicit()
563572
} else {
564573
if (now - lastHiddenAt >= minHiddenDurationMs) {
565574
if (getOperatingSystem() == WINDOWS && (now - lastFocusLostAt) < 300) {
@@ -591,27 +600,47 @@ private fun ApplicationScope.TrayAppImplOriginal(
591600
pendingPosition = null
592601

593602
val position = if (preComputed != null && preComputed !is WindowPosition.PlatformDefault) {
603+
debugln { "[TrayApp] Using preComputed position: $preComputed" }
594604
preComputed
595605
} else {
596606
// Fallback: poll for position (e.g. initiallyVisible or programmatic show)
597-
delay(250)
607+
// Wait for Windows to finish reorganizing tray icons after adding a new one.
608+
// Windows moves icons around after creation, so we need to wait and re-poll.
609+
debugln { "[TrayApp] No preComputed position, waiting for tray to stabilize..." }
610+
delay(400) // Give Windows time to reorganize tray icons
611+
598612
val widthPx = currentWindowSize.width.value.toInt()
599613
val heightPx = currentWindowSize.height.value.toInt()
614+
615+
// On Windows, force a fresh position capture via the native API
616+
if (getOperatingSystem() == WINDOWS) {
617+
debugln { "[TrayApp] Re-capturing tray position from native API..." }
618+
WindowsTrayInitializer.refreshPosition(tray.instanceKey())
619+
delay(50) // Let the position update propagate
620+
}
621+
600622
var pos: WindowPosition = WindowPosition.PlatformDefault
601623
val deadline = System.currentTimeMillis() + 3000
602624
while (pos is WindowPosition.PlatformDefault && System.currentTimeMillis() < deadline) {
603625
pos = getTrayWindowPositionForInstance(
604626
tray.instanceKey(), widthPx, heightPx, horizontalOffset, verticalOffset
605627
)
628+
debugln { "[TrayApp] Polled position: $pos" }
606629
if (pos is WindowPosition.PlatformDefault) delay(250)
607630
}
608631
pos
609632
}
633+
debugln { "[TrayApp] Setting dialogState.position = $position" }
610634
dialogState.position = position
611635

636+
// Wait for Compose to apply the position before showing the window
637+
// This prevents the window from flashing at the wrong position
638+
delay(150) // Give Compose time to recompose with new position
639+
612640
if (getOperatingSystem() == WINDOWS) {
613641
autoHideEnabledAt = System.currentTimeMillis() + 1000
614642
}
643+
debugln { "[TrayApp] Now showing window" }
615644
shouldShowWindow = true
616645
lastShownAt = System.currentTimeMillis()
617646
}
@@ -665,12 +694,15 @@ private fun ApplicationScope.TrayAppImplOriginal(
665694
try { window.name = WindowVisibilityMonitor.TRAY_DIALOG_NAME } catch (_: Throwable) {}
666695
runCatching { WindowVisibilityMonitor.recompute() }
667696

697+
debugln { "[TrayApp] Window shown at native position: x=${window.x}, y=${window.y}, dialogState.position=${dialogState.position}" }
698+
668699
invokeLater {
669700
// Move the popup to the current Space before bringing it to front (macOS)
670701
if (getOperatingSystem() == MACOS) {
671702
runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() }
672703
runCatching { MacOSWindowManager().setMoveToActiveSpace(window) }
673704
}
705+
debugln { "[TrayApp] After invokeLater: window at x=${window.x}, y=${window.y}" }
674706
runCatching {
675707
window.toFront()
676708
window.requestFocus()

src/commonMain/kotlin/com/kdroid/composetray/tray/impl/WindowsTrayInitializer.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ object WindowsTrayInitializer {
5050
trayManagers.remove(id)?.stopTray()
5151
}
5252

53+
/**
54+
* Force a fresh capture of the tray icon position.
55+
* This is useful when Windows reorganizes icons after creation.
56+
*/
57+
@Synchronized
58+
fun refreshPosition(id: String) {
59+
trayManagers[id]?.refreshPosition()
60+
}
61+
5362
// Backward-compatible API for existing callers (single default tray)
5463
fun initialize(iconPath: String, tooltip: String, onLeftClick: (() -> Unit)? = null, menuContent: (TrayMenuBuilder.() -> Unit)? = null) =
5564
initialize(DEFAULT_ID, iconPath, tooltip, onLeftClick, menuContent)

src/commonMain/kotlin/com/kdroid/composetray/utils/TrayPosition.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,10 @@ fun getTrayWindowPositionForInstance(
330330
return when (os) {
331331
OperatingSystem.WINDOWS -> {
332332
val pos = TrayClickTracker.getLastClickPosition(instanceId)
333-
?: return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
333+
if (pos == null) {
334+
debugln { "[TrayPosition] getTrayWindowPositionForInstance: no position for $instanceId, using fallback" }
335+
return fallbackCornerPosition(windowWidth, windowHeight, horizontalOffset, verticalOffset)
336+
}
334337
calculateWindowPositionFromClick(
335338
pos.x, pos.y, pos.position,
336339
windowWidth, windowHeight,
@@ -385,16 +388,19 @@ private fun calculateWindowPositionFromClick(
385388
val isRight = trayPosition == TrayPosition.TOP_RIGHT || trayPosition == TrayPosition.BOTTOM_RIGHT
386389

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

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

393398
x += horizontalOffset
394399
y += verticalOffset
395400

396401
if (x < sb.x) x = sb.x else if (x + windowWidth > sb.x + sb.width) x = sb.x + sb.width - windowWidth
397402
if (y < sb.y) y = sb.y else if (y + windowHeight > sb.y + sb.height) y = sb.y + sb.height - windowHeight
403+
debugln { "[TrayPosition] Windows: final x=$x, y=$y" }
398404
WindowPosition(x = x.dp, y = y.dp)
399405
} else {
400406
val panelGuessPx = 28

0 commit comments

Comments
 (0)