Skip to content

Commit 1faadff

Browse files
authored
Merge pull request #371 from kdroidFilter/fix/windows-tray-initial-position
Fix Windows tray window position on first display
2 parents 18eef46 + 5dafc60 commit 1faadff

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)