desktop: set hover cursor directly on every hover move; compose pointerHoverIcon is edge-triggered and loses updates when chat items shift under the cursor

This commit is contained in:
Narasimha-sc
2026-07-13 19:33:18 +00:00
parent 601f8e3712
commit df6b0655de
4 changed files with 44 additions and 6 deletions
@@ -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
@@ -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
@@ -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)
@@ -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) }