mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-01 02:50:13 +00:00
desktop: harden hover cursor fix after adversarial review: refresh on release, ignore button-held events, reset icon state on exit, cache canvas lookup failures
This commit is contained in:
+2
-1
@@ -3,6 +3,7 @@ package chat.simplex.common.platform
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
@@ -17,6 +18,6 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
|
||||
|
||||
actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this
|
||||
|
||||
actual fun desktopSetHoverCursor(hand: Boolean) {}
|
||||
actual fun desktopSetHoverCursor(icon: PointerIcon) {}
|
||||
|
||||
actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier
|
||||
|
||||
+9
-6
@@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.pointer.PointerIcon
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.views.helpers.KeyChangeEffect
|
||||
@@ -27,13 +28,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.
|
||||
* Directly sets the mouse cursor on desktop ([PointerIcon.Hand], [PointerIcon.Text], anything else
|
||||
* means default). Compose's pointerHoverIcon displays icons only on Enter/Exit edges or on icon
|
||||
* change while marked in-bounds — edges that are silently lost when chat items shift/recompose
|
||||
* under the cursor, leaving a stale cursor. Calling this on every hover move (and resetting on
|
||||
* exit) keeps it self-correcting. No-op on Android.
|
||||
* Verified against Compose 1.8.2, re-verify on upgrade (JetBrains/compose-multiplatform #2091,
|
||||
* #1314, #3750). Details: plans/2026-07-13-fix-command-hover-cursor.md
|
||||
*/
|
||||
expect fun desktopSetHoverCursor(hand: Boolean)
|
||||
expect fun desktopSetHoverCursor(icon: PointerIcon)
|
||||
|
||||
expect fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier
|
||||
|
||||
|
||||
+11
-4
@@ -357,9 +357,15 @@ fun MarkdownText (
|
||||
onHover = { offset ->
|
||||
val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) }
|
||||
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
|
||||
val newIcon = if (hand) PointerIcon.Hand else PointerIcon.Text
|
||||
icon.value = newIcon
|
||||
// pointerHoverIcon alone loses updates when items shift/recompose under the cursor, see desktopSetHoverCursor
|
||||
desktopSetHoverCursor(hand)
|
||||
desktopSetHoverCursor(newIcon)
|
||||
},
|
||||
onHoverExit = {
|
||||
// also reset icon.value, or the pointerHoverIcon node re-displays a stale Hand on its next Enter
|
||||
icon.value = PointerIcon.Text
|
||||
desktopSetHoverCursor(PointerIcon.Default)
|
||||
},
|
||||
shouldConsumeEvent = { offset ->
|
||||
annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset)
|
||||
@@ -392,6 +398,7 @@ fun ClickableText(
|
||||
onClick: (Int) -> Unit,
|
||||
onLongClick: (Int) -> Unit = {},
|
||||
onHover: (Int) -> Unit = {},
|
||||
onHoverExit: () -> Unit = {},
|
||||
shouldConsumeEvent: (Int) -> Boolean
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
@@ -415,9 +422,9 @@ fun ClickableText(
|
||||
consume
|
||||
}
|
||||
)
|
||||
}.pointerInput(onHover) {
|
||||
}.pointerInput(onHover, onHoverExit) {
|
||||
if (appPlatform.isDesktop) {
|
||||
detectCursorMove { pos ->
|
||||
detectCursorMove(onExit = onHoverExit) { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onHover(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
|
||||
+11
-6
@@ -92,15 +92,20 @@ suspend fun PointerInputScope.detectGesture(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) {
|
||||
// One never-exiting scope: exiting/re-entering awaitPointerEventScope per event (as forEachGesture did)
|
||||
// drops events that arrive between scopes, leaving a stale cursor icon.
|
||||
// Enter matters too: when content shifts under a stationary cursor, the newly hovered item gets Enter, not Move.
|
||||
suspend fun PointerInputScope.detectCursorMove(onExit: () -> Unit = {}, onMove: (Offset) -> Unit = {},) {
|
||||
// Single scope: re-entering awaitPointerEventScope per event (as forEachGesture did) drops events.
|
||||
// Enter/Release matter too: a stationary cursor gets no Move when content shifts or after a click.
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.type == PointerEventType.Move || event.type == PointerEventType.Enter) {
|
||||
onMove(event.changes[0].position)
|
||||
if (event.type == PointerEventType.Move || event.type == PointerEventType.Enter || event.type == PointerEventType.Release) {
|
||||
val pos = event.changes[0].position
|
||||
// while a button is held the pressed node keeps receiving events even outside its bounds
|
||||
if (event.changes.none { it.pressed } && pos.x >= 0 && pos.y >= 0 && pos.x < size.width && pos.y < size.height) {
|
||||
onMove(pos)
|
||||
}
|
||||
} else if (event.type == PointerEventType.Exit) {
|
||||
onExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-13
@@ -13,12 +13,14 @@ import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Cursor
|
||||
import java.awt.Image
|
||||
import javax.swing.SwingUtilities
|
||||
import java.awt.Window
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.net.URI
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.desktopOnExternalDrag(
|
||||
@@ -86,24 +88,35 @@ 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
|
||||
// Runs on every mouse move, so the canvas lookup (and its failure) is cached per window;
|
||||
// weak refs avoid retaining a recreated window's scene graph.
|
||||
private var cachedSkiaCanvas: WeakReference<Component>? = null
|
||||
private var skiaCanvasMissingIn: WeakReference<Window>? = null
|
||||
|
||||
actual fun desktopSetHoverCursor(hand: Boolean) {
|
||||
actual fun desktopSetHoverCursor(icon: PointerIcon) {
|
||||
// clickable message text only exists in the main window
|
||||
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
|
||||
val type = when (icon) {
|
||||
PointerIcon.Hand -> Cursor.HAND_CURSOR
|
||||
PointerIcon.Text -> Cursor.TEXT_CURSOR
|
||||
else -> Cursor.DEFAULT_CURSOR
|
||||
}
|
||||
var canvas = cachedSkiaCanvas?.get()
|
||||
if (canvas == null || SwingUtilities.getWindowAncestor(canvas) !== window) {
|
||||
if (skiaCanvasMissingIn?.get() === window) return
|
||||
canvas = findSkiaCanvas(window)
|
||||
if (canvas == null) {
|
||||
Log.w(TAG, "desktopSetHoverCursor: Skia canvas not found, hover cursor workaround disabled")
|
||||
skiaCanvasMissingIn = WeakReference(window)
|
||||
return
|
||||
}
|
||||
cachedSkiaCanvas = WeakReference(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).
|
||||
// Compose writes pointer icons to its skia canvas component (ComposeSceneMediator.setPointerIcon);
|
||||
// writing to the same component keeps last-write-wins consistent with the framework's own updates.
|
||||
private fun findSkiaCanvas(c: Component): Component? = when {
|
||||
c.javaClass.name.contains("Skia") -> c
|
||||
c is Container -> c.components.firstNotNullOfOrNull { findSkiaCanvas(it) }
|
||||
|
||||
Reference in New Issue
Block a user