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 4b85d800ff..57662d86ef 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 @@ -29,12 +29,9 @@ expect fun Modifier.desktopPointerHoverIconHand(): Modifier /** * 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 + * means default); no-op on Android. Compose's pointerHoverIcon is edge-triggered and silently loses + * updates when chat items shift/recompose under the cursor; calling this on every hover move (and + * resetting on exit) keeps the cursor correct. Details: plans/2026-07-13-fix-command-hover-cursor.md */ expect fun desktopSetHoverCursor(icon: PointerIcon) 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 e901bab7fe..556fa04832 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 @@ -359,13 +359,12 @@ fun MarkdownText ( val hand = hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") 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 + // pointerHoverIcon alone loses updates, see desktopSetHoverCursor desktopSetHoverCursor(newIcon) }, onHoverExit = { - // also reset icon.value, or the pointerHoverIcon node re-displays a stale Hand on its next Enter; - // values differ on purpose: Text is this node's neutral icon, Default is what the cursor - // should be outside the text + // reset icon.value too, or its node re-displays a stale Hand on next Enter; + // Text = this node's neutral icon, Default = cursor outside the text icon.value = PointerIcon.Text desktopSetHoverCursor(PointerIcon.Default) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt index 7e9ea10b57..21c450ce27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt @@ -93,14 +93,13 @@ suspend fun PointerInputScope.detectGesture( } 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. + // Single scope: per-event scope re-entry (old forEachGesture) drops events; Enter/Release cover stationary cursors. awaitPointerEventScope { while (true) { val event = awaitPointerEvent() if (event.type == PointerEventType.Move || event.type == PointerEventType.Enter || event.type == PointerEventType.Release) { val pos = event.changes[0].position - // pressed nodes keep receiving events outside their bounds, both while held and at release + // pressed nodes receive events outside their bounds (during drag and at release) if (event.changes.none { it.pressed } && pos.x >= 0 && pos.y >= 0 && pos.x < size.width && pos.y < size.height) { onMove(pos) } 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 ed5c32ee4f..0049fe0a41 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 @@ -88,8 +88,7 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpen actual fun Modifier.desktopPointerHoverIconHand(): Modifier = Modifier.pointerHoverIcon(PointerIcon.Hand) -// 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. +// hot path: canvas lookup (and its failure) cached per window, weakly to not retain a recreated window private var cachedSkiaCanvas: WeakReference? = null private var skiaCanvasMissingIn: WeakReference? = null @@ -115,8 +114,7 @@ actual fun desktopSetHoverCursor(icon: PointerIcon) { if (canvas.cursor.type != type) canvas.cursor = Cursor.getPredefinedCursor(type) } -// 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. +// the same component Compose writes pointer icons to (ComposeSceneMediator.setPointerIcon) private fun findSkiaCanvas(c: Component): Component? = when { c.javaClass.name.contains("Skia") -> c c is Container -> c.components.firstNotNullOfOrNull { findSkiaCanvas(it) }