Kaynağa Gözat

standalone auto-hide tooltip window

Milan Jurkulak 3 ay önce
ebeveyn
işleme
6b0f319ead

+ 0 - 2
androidApp/src/main/kotlin/org/mjdev/desktop/main/MainView.kt

@@ -70,7 +70,6 @@ fun MainView(onBackgroundChange: (Color) -> Unit = {}) =
             portrait = {
             portrait = {
                 // todo menu & etc
                 // todo menu & etc
                 Desktop(
                 Desktop(
-                    tooltipState = tooltipState,
                     onTooltip = onTooltip,
                     onTooltip = onTooltip,
                     padding =
                     padding =
                         PaddingValues(
                         PaddingValues(
@@ -103,7 +102,6 @@ fun MainView(onBackgroundChange: (Color) -> Unit = {}) =
                     contentAlignment = Alignment.Center,
                     contentAlignment = Alignment.Center,
                 ) {
                 ) {
                     Desktop(
                     Desktop(
-                        tooltipState = tooltipState,
                         onTooltip = onTooltip,
                         onTooltip = onTooltip,
                         padding =
                         padding =
                             PaddingValues(
                             PaddingValues(

+ 4 - 19
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/Desktop.kt

@@ -12,17 +12,11 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import org.jetbrains.compose.ui.tooling.preview.Preview
 import org.jetbrains.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.adb.AdbScreenMirror
 import org.mjdev.desktop.components.adb.AdbScreenMirror
 import org.mjdev.desktop.components.background.BackgroundImage
 import org.mjdev.desktop.components.background.BackgroundImage
 import org.mjdev.desktop.components.file.FolderView
 import org.mjdev.desktop.components.file.FolderView
-import org.mjdev.desktop.components.tooltip.Tooltip
-import org.mjdev.desktop.components.tooltip.TooltipArea
-import org.mjdev.desktop.components.tooltip.TooltipPlacement
-import org.mjdev.desktop.components.tooltip.TooltipState
-import org.mjdev.desktop.components.tooltip.rememberTooltipState
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
 import org.mjdev.desktop.extensions.Compose.preview
 import org.mjdev.desktop.extensions.Compose.preview
 import org.mjdev.desktop.extensions.Modifier.onLeftClick
 import org.mjdev.desktop.extensions.Modifier.onLeftClick
@@ -32,7 +26,6 @@ import org.mjdev.desktop.extensions.Modifier.onRightClick
 @Suppress("FunctionName", "UNUSED_PARAMETER")
 @Suppress("FunctionName", "UNUSED_PARAMETER")
 @Composable
 @Composable
 fun Desktop(
 fun Desktop(
-    tooltipState: TooltipState = rememberTooltipState(),
     padding: PaddingValues = PaddingValues(),
     padding: PaddingValues = PaddingValues(),
     onTooltip: (item: Any?) -> Unit = {},
     onTooltip: (item: Any?) -> Unit = {},
     onLeftMouseClick: () -> Unit = {},
     onLeftMouseClick: () -> Unit = {},
@@ -41,19 +34,11 @@ fun Desktop(
     // todo
     // todo
     widgets: @Composable () -> Unit = {},
     widgets: @Composable () -> Unit = {},
 ) = withDesktopContext {
 ) = withDesktopContext {
-    TooltipArea(
+    // Tooltips are no longer rendered here — components push into TooltipState via
+    // onTooltip and the desktop shell shows them in a standalone auto-hide window
+    // (see desktopMain TooltipWindow).
+    Box(
         modifier = Modifier.fillMaxSize(),
         modifier = Modifier.fillMaxSize(),
-        delayMillis = 2000,
-        tooltipPlacement =
-            TooltipPlacement.CursorPoint(
-                alignment = Alignment.TopStart,
-                offset = DpOffset(0.dp, 16.dp),
-            ),
-        tooltip = {
-            Tooltip(
-                tooltipState = tooltipState,
-            )
-        },
     ) {
     ) {
         BackgroundImage(
         BackgroundImage(
             modifier =
             modifier =

+ 0 - 113
shared/src/commonMain/kotlin/org/mjdev/desktop/components/tooltip/TooltipArea.kt

@@ -1,113 +0,0 @@
-package org.mjdev.desktop.components.tooltip
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Rect
-import androidx.compose.ui.input.pointer.PointerEvent
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.PointerEventType
-import androidx.compose.ui.input.pointer.areAnyPressed
-import androidx.compose.ui.layout.boundsInWindow
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.layout.positionInWindow
-import androidx.compose.ui.unit.DpOffset
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Popup
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import org.jetbrains.compose.ui.tooling.preview.Preview
-import org.mjdev.desktop.extensions.Modifier.onPointerEvent
-
-val PointerEvent.position get() = changes.first().position
-
-@Composable
-fun TooltipArea(
-    tooltip: @Composable () -> Unit,
-    modifier: Modifier = Modifier,
-    delayMillis: Int = 500,
-    tooltipPlacement: TooltipPlacement =
-        TooltipPlacement.ComponentRect(
-            offset = DpOffset(0.dp, 16.dp),
-        ),
-    content: @Composable BoxScope.() -> Unit,
-) {
-    var parentBounds by remember { mutableStateOf(Rect.Zero) }
-    var cursorPosition by remember { mutableStateOf(Offset.Zero) }
-    var isVisible by remember { mutableStateOf(false) }
-    val scope = rememberCoroutineScope()
-    var job: Job? by remember { mutableStateOf(null) }
-
-    fun startShowing() {
-        if (job?.isActive == true) return
-        job =
-            scope.launch {
-                delay(delayMillis.toLong())
-                isVisible = true
-            }
-    }
-
-    fun hide() {
-        job?.cancel()
-        job = null
-        isVisible = false
-    }
-
-    fun hideIfNotHovered(globalPosition: Offset) {
-        if (!parentBounds.contains(globalPosition)) {
-            hide()
-        }
-    }
-
-    Box(
-        modifier =
-            modifier
-                .onGloballyPositioned { parentBounds = it.boundsInWindow() }
-                .onPointerEvent(PointerEventType.Enter) {
-                    cursorPosition = it.position
-                    if (!isVisible && !it.buttons.areAnyPressed) {
-                        startShowing()
-                    }
-                }.onPointerEvent(PointerEventType.Move) {
-                    cursorPosition = it.position
-                    if (!isVisible && !it.buttons.areAnyPressed) {
-                        startShowing()
-                    }
-                }.onPointerEvent(PointerEventType.Exit) {
-                    hideIfNotHovered(parentBounds.topLeft + it.position)
-                }.onPointerEvent(PointerEventType.Press, pass = PointerEventPass.Initial) {
-                    hide()
-                },
-    ) {
-        content()
-        if (isVisible) {
-            Popup(
-                popupPositionProvider = tooltipPlacement.positionProvider(cursorPosition),
-                onDismissRequest = { isVisible = false },
-            ) {
-                var popupPosition by remember { mutableStateOf(Offset.Zero) }
-                Box(
-                    Modifier
-                        .onGloballyPositioned { popupPosition = it.positionInWindow() }
-                        .onPointerEvent(PointerEventType.Move) {
-                            hideIfNotHovered(popupPosition + it.position)
-                        }.onPointerEvent(PointerEventType.Exit) {
-                            hideIfNotHovered(popupPosition + it.position)
-                        },
-                ) {
-                    tooltip()
-                }
-            }
-        }
-    }
-}
-
-// todo preview

+ 0 - 157
shared/src/commonMain/kotlin/org/mjdev/desktop/components/tooltip/TooltipPlacement.kt

@@ -1,157 +0,0 @@
-package org.mjdev.desktop.components.tooltip
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.DpOffset
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.IntRect
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.LayoutDirection
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.round
-import androidx.compose.ui.window.PopupPositionProvider
-import org.jetbrains.compose.ui.tooling.preview.Preview
-import kotlin.math.roundToInt
-
-interface TooltipPlacement {
-    @Composable
-    fun positionProvider(cursorPosition: Offset): PopupPositionProvider
-
-    class CursorPoint(
-        private val offset: DpOffset = DpOffset.Zero,
-        private val alignment: Alignment = Alignment.BottomEnd,
-        private val windowMargin: Dp = 4.dp,
-    ) : TooltipPlacement {
-        @Composable
-        override fun positionProvider(cursorPosition: Offset) = rememberPopupPositionProviderAtPosition(
-            positionPx = cursorPosition,
-            offset = offset,
-            alignment = alignment,
-            windowMargin = windowMargin,
-        )
-    }
-
-    class ComponentRect(
-        private val anchor: Alignment = Alignment.BottomCenter,
-        private val alignment: Alignment = Alignment.BottomCenter,
-        private val offset: DpOffset = DpOffset.Zero,
-    ) : TooltipPlacement {
-        @Composable
-        override fun positionProvider(cursorPosition: Offset) = rememberComponentRectPositionProvider(
-            anchor = anchor,
-            alignment = alignment,
-            offset = offset,
-        )
-    }
-}
-
-class PopupPositionProviderAtPosition(
-    val positionPx: Offset,
-    val isRelativeToAnchor: Boolean,
-    val offsetPx: Offset,
-    val alignment: Alignment = Alignment.BottomEnd,
-    val windowMarginPx: Int,
-) : PopupPositionProvider {
-    override fun calculatePosition(
-        anchorBounds: IntRect,
-        windowSize: IntSize,
-        layoutDirection: LayoutDirection,
-        popupContentSize: IntSize,
-    ): IntOffset {
-        val anchor =
-            IntRect(
-                offset =
-                    positionPx.round() +
-                        (if (isRelativeToAnchor) anchorBounds.topLeft else IntOffset.Zero),
-                size = IntSize.Zero,
-            )
-        val tooltipArea =
-            IntRect(
-                IntOffset(
-                    anchor.left - popupContentSize.width,
-                    anchor.top - popupContentSize.height,
-                ),
-                IntSize(
-                    popupContentSize.width * 2,
-                    popupContentSize.height * 2,
-                ),
-            )
-        val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
-        var x = tooltipArea.left + position.x + offsetPx.x
-        var y = tooltipArea.top + position.y + offsetPx.y
-        if (x + popupContentSize.width > windowSize.width - windowMarginPx) {
-            x -= popupContentSize.width
-        }
-        if (y + popupContentSize.height > windowSize.height - windowMarginPx) {
-            y -= popupContentSize.height + anchor.height
-        }
-        x = x.coerceAtLeast(windowMarginPx.toFloat())
-        y = y.coerceAtLeast(windowMarginPx.toFloat())
-
-        return IntOffset(x.roundToInt(), y.roundToInt())
-    }
-}
-
-@Composable
-fun rememberPopupPositionProviderAtPosition(
-    positionPx: Offset,
-    offset: DpOffset = DpOffset.Zero,
-    alignment: Alignment = Alignment.BottomEnd,
-    windowMargin: Dp = 4.dp,
-): PopupPositionProvider = with(LocalDensity.current) {
-    val offsetPx = Offset(offset.x.toPx(), offset.y.toPx())
-    val windowMarginPx = windowMargin.roundToPx()
-
-    remember(positionPx, offsetPx, alignment, windowMarginPx) {
-        PopupPositionProviderAtPosition(
-            positionPx = positionPx,
-            isRelativeToAnchor = true,
-            offsetPx = offsetPx,
-            alignment = alignment,
-            windowMarginPx = windowMarginPx,
-        )
-    }
-}
-
-@Composable
-fun rememberComponentRectPositionProvider(
-    anchor: Alignment = Alignment.BottomCenter,
-    alignment: Alignment = Alignment.BottomCenter,
-    offset: DpOffset = DpOffset.Zero,
-): PopupPositionProvider {
-    val offsetPx =
-        with(LocalDensity.current) {
-            IntOffset(offset.x.roundToPx(), offset.y.roundToPx())
-        }
-    return remember(anchor, alignment, offsetPx) {
-        object : PopupPositionProvider {
-            override fun calculatePosition(
-                anchorBounds: IntRect,
-                windowSize: IntSize,
-                layoutDirection: LayoutDirection,
-                popupContentSize: IntSize,
-            ): IntOffset {
-                val anchorPoint = anchor.align(IntSize.Zero, anchorBounds.size, layoutDirection)
-                val tooltipArea =
-                    IntRect(
-                        IntOffset(
-                            anchorBounds.left + anchorPoint.x - popupContentSize.width,
-                            anchorBounds.top + anchorPoint.y - popupContentSize.height,
-                        ),
-                        IntSize(
-                            popupContentSize.width * 2,
-                            popupContentSize.height * 2,
-                        ),
-                    )
-                val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
-                return tooltipArea.topLeft + position + offsetPx
-            }
-        }
-    }
-}
-
-// todo previews

+ 5 - 1
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -23,6 +23,7 @@ import org.mjdev.desktop.components.info.InfoWindow
 import org.mjdev.desktop.components.installer.InstallerWindow
 import org.mjdev.desktop.components.installer.InstallerWindow
 import org.mjdev.desktop.components.sliding.base.VisibilityState.Companion.rememberVisibilityState
 import org.mjdev.desktop.components.sliding.base.VisibilityState.Companion.rememberVisibilityState
 import org.mjdev.desktop.components.tooltip.TooltipState
 import org.mjdev.desktop.components.tooltip.TooltipState
+import org.mjdev.desktop.components.tooltip.TooltipWindow
 import org.mjdev.desktop.components.tooltip.rememberTooltipState
 import org.mjdev.desktop.components.tooltip.rememberTooltipState
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
 import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
 import org.mjdev.desktop.extensions.Compose.isDesign
 import org.mjdev.desktop.extensions.Compose.isDesign
@@ -90,7 +91,6 @@ fun MainWindow() = withDesktopContext {
         menuState = menuState,
         menuState = menuState,
     ) {
     ) {
         Desktop(
         Desktop(
-            tooltipState = tooltipState,
             onTooltip = onTooltip,
             onTooltip = onTooltip,
             padding =
             padding =
                 PaddingValues(
                 PaddingValues(
@@ -174,6 +174,10 @@ fun MainWindow() = withDesktopContext {
             }
             }
         },
         },
     )
     )
+    // Standalone auto-hide tooltip window — every onTooltip above lands here.
+    TooltipWindow(
+        tooltipState = tooltipState,
+    )
     GreeterWindow()
     GreeterWindow()
     InfoWindow(
     InfoWindow(
         visibleState = infoWindowState,
         visibleState = infoWindowState,

+ 104 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/tooltip/TooltipWindow.kt

@@ -0,0 +1,104 @@
+package org.mjdev.desktop.components.tooltip
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.desktop.ui.tooling.preview.Preview
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.windows.ChromeWindow
+import org.mjdev.desktop.windows.ChromeWindowState
+import org.mjdev.desktop.windows.ChromeWindowState.Companion.rememberChromeWindowState
+import java.awt.MouseInfo
+
+/**
+ * Standalone tooltip window — a small, transparent, non-focusable, always-on-top
+ * [ChromeWindow] shown next to the mouse pointer whenever [TooltipState] carries a value
+ * (components push into it via their `onTooltip` callbacks). It auto-hides: after
+ * [hideDelay] the content fades out over [fadeDuration] and the window disappears.
+ * No hover-tracking popup logic — show, wait, fade, gone.
+ */
+@Suppress("FunctionName")
+@Composable
+fun TooltipWindow(
+    tooltipState: TooltipState = rememberTooltipState(),
+    hideDelay: Long = 3_000L,
+    fadeDuration: Int = 400,
+    size: DpSize = DpSize(360.dp, 96.dp),
+    cursorOffset: DpOffset = DpOffset(16.dp, 24.dp),
+    windowState: ChromeWindowState = rememberChromeWindowState(),
+) = withDesktopContext {
+    var contentVisible by remember { mutableStateOf(false) }
+    val item = tooltipState.value
+    LaunchedEffect(item) {
+        if (item != null) {
+            windowState.position = pointerPosition(cursorOffset, size, containerSize)
+            contentVisible = true
+            delay(hideDelay)
+            contentVisible = false
+            delay(fadeDuration.toLong())
+            tooltipState.clear()
+        } else {
+            contentVisible = false
+        }
+    }
+    ChromeWindow(
+        name = "Tooltip",
+        visible = item != null,
+        transparent = true,
+        focusable = false,
+        alwaysOnTop = true,
+        size = size,
+        windowState = windowState,
+        onCreated = {
+            windowState.size = size
+        },
+    ) {
+        AnimatedVisibility(
+            visible = contentVisible,
+            enter = fadeIn(tween(fadeDuration)),
+            exit = fadeOut(tween(fadeDuration)),
+        ) {
+            Tooltip(tooltipState = tooltipState)
+        }
+    }
+}
+
+/**
+ * Window position next to the current mouse pointer, clamped so the tooltip never
+ * leaves the screen. AWT reports the pointer in px; shell windows are positioned 1:1
+ * px-to-dp (see Window.setPositionImpl), so the raw values are used as dp directly.
+ */
+private fun pointerPosition(
+    cursorOffset: DpOffset,
+    size: DpSize,
+    containerSize: DpSize,
+): DpOffset {
+    val pointer = runCatching { MouseInfo.getPointerInfo()?.location }.getOrNull()
+    val x = (pointer?.x ?: 0).dp + cursorOffset.x
+    val y = (pointer?.y ?: 0).dp + cursorOffset.y
+    return DpOffset(
+        x = x.coerceIn(0.dp, (containerSize.width - size.width).coerceAtLeast(0.dp)),
+        y = y.coerceIn(0.dp, (containerSize.height - size.height).coerceAtLeast(0.dp)),
+    )
+}
+
+@Suppress("unused")
+@Preview
+@Composable
+fun PreviewTooltipWindow() = preview {
+    TooltipWindow(
+        tooltipState = rememberTooltipState().apply { show("test tooltip") },
+    )
+}