diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 5d07aae088..8bee171157 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -17,4 +17,6 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this +actual fun desktopSetHoverCursor(hand: Boolean) {} + actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index be7022ca80..76b98990d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -26,6 +26,15 @@ expect fun Modifier.onRightClick(action: () -> Unit): Modifier expect fun Modifier.desktopPointerHoverIconHand(): Modifier +/** + * Directly sets the mouse cursor (hand or text) on desktop, bypassing Compose's pointerHoverIcon. + * pointerHoverIcon is edge-triggered (it displays the icon only on a per-node Enter event, or on an + * icon change while the node is marked in-bounds) and those edges can be silently lost when chat + * items shift/recompose under the cursor, leaving a stale cursor. Calling this on every hover move + * keeps the cursor self-correcting. No-op on Android. + */ +expect fun desktopSetHoverCursor(hand: Boolean) + expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index c7c96cd731..3b50441b21 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -356,12 +356,10 @@ fun MarkdownText ( }, onHover = { offset -> val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } - icon.value = - if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { - PointerIcon.Hand - } else { - PointerIcon.Text - } + val hand = hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") + icon.value = if (hand) PointerIcon.Hand else PointerIcon.Text + // pointerHoverIcon alone loses updates when items shift/recompose under the cursor, see desktopSetHoverCursor + desktopSetHoverCursor(hand) }, shouldConsumeEvent = { offset -> annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index b090e301d5..0a5101236e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -8,7 +8,12 @@ import androidx.compose.ui.* import androidx.compose.ui.draganddrop.* import androidx.compose.ui.draganddrop.DragData import androidx.compose.ui.input.pointer.* +import chat.simplex.common.simplexWindowState +import java.awt.Component +import java.awt.Container +import java.awt.Cursor import java.awt.Image +import javax.swing.SwingUtilities import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.Transferable import java.awt.image.BufferedImage @@ -81,6 +86,30 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpen actual fun Modifier.desktopPointerHoverIconHand(): Modifier = Modifier.pointerHoverIcon(PointerIcon.Hand) +// This runs on every mouse move over clickable text, so the canvas lookup is cached: +// steady-state cost is a parent-chain check plus an int comparison, no allocations. +private var cachedSkiaCanvas: Component? = null + +actual fun desktopSetHoverCursor(hand: Boolean) { + val window = simplexWindowState.window ?: return + val type = if (hand) Cursor.HAND_CURSOR else Cursor.TEXT_CURSOR + var canvas = cachedSkiaCanvas + if (canvas == null || !canvas.isDisplayable || SwingUtilities.getWindowAncestor(canvas) !== window) { + canvas = findSkiaCanvas(window) ?: return + cachedSkiaCanvas = canvas + } + if (canvas.cursor.type != type) canvas.cursor = Cursor.getPredefinedCursor(type) +} + +// Compose writes the pointer icon to its skia canvas component (ComposeSceneMediator.setPointerIcon +// -> contentComponent.cursor, a SkiaLayer/SkiaSwingLayer). Writing to the same component keeps +// last-write-wins consistent with the framework's own cursor updates (e.g. reset to default on exit). +private fun findSkiaCanvas(c: Component): Component? = when { + c.javaClass.name.contains("Skia") -> c + c is Container -> c.components.firstNotNullOfOrNull { findSkiaCanvas(it) } + else -> null +} + actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = this then Modifier .onPointerEvent(PointerEventType.Enter) { action(true) }