* desktop: fix pointer cursor not changing to hand over clickable commands (dropped hover events)
* desktop: set hover cursor directly on every hover move; compose pointerHoverIcon is edge-triggered and loses updates when chat items shift under the cursor
* plans: investigation and justification for command hover cursor fix
* desktop: harden hover cursor fix after adversarial review: refresh on release, ignore button-held events, reset icon state on exit, cache canvas lookup failures
* desktop: document two-stage verification, exit icon asymmetry and cache guard rationale in hover cursor fix
* plans: reconcile performance and testing claims with hardened code
* plans: correct recomposition count and note commit subject scoping
* plans: clarify user-facing problem description
* desktop: shorten comments in hover cursor fix
* desktop: clarify comments in hover cursor fix
* multiplatform: fix command clicks lost on quick successive clicks (serialize gesture press scope, don't restart pointerInput on recomposition)
* plans: document lost-click defect (press-scope race, pointerInput restart) and fixes
* multiplatform: don't cancel command click when chat list shifts under the pointer
A press is cancelled when it goes out of the node's bounds, but when a sent
message inserts into the chat the node moves out from under a stationary
pointer, which is not a drag-away. Exempt out-of-bounds cancellation when the
pointer did not move in window coordinates (within touch slop).
* multiplatform: stop pointer handler resets on chat item recomposition (lost clicks, cursor flicker)
bigTouchSlop() created a new ViewConfiguration instance on every recomposition
of every chat item; pointer input nodes observe ViewConfiguration and reset
their handler when it changes, so each inserted message killed all in-flight
presses (lost command clicks) and hover handlers (hand cursor flicker) in the
viewport. Provide a remembered instance instead.
* plans: document list-shift press cancellation and ViewConfiguration reset defects
* desktop: remove imperative hover cursor workaround, superseded by ViewConfiguration fix
The evidence that the declarative path was insufficient was gathered while
every message insertion was resetting all hover handlers (the ViewConfiguration
identity defect, fixed in 6d24bd5d4) — which alone explains those failures.
With handlers stable, the full hover matrix passes on pointerHoverIcon plus the
lossless detectCursorMove alone, so the AWT canvas write and its expect/actuals
are removed; only the icon state reset on Exit remains.
* plans: document hover workaround removal and stage-1 evidence contamination
17 KiB
Fix: hand cursor not appearing over clickable commands (desktop)
Problem
On desktop, the mouse cursor should change to a hand over clickable commands in chat messages
(e.g. /join 143556 in a bot's menu of commands) — the hand is what tells the user the command
can be clicked. This did not work reliably: hovering a command sometimes left the text/arrow
cursor in place, giving no indication that the command is clickable. It usually worked, but failed
intermittently — most often when clicking many commands in a row in a chat, because every click
inserts the sent message and shifts the list under the pointer — and once wrong, the cursor stayed
wrong even while moving within the same message; it only recovered after leaving the message text
and re-entering it. The same mechanism affects all clickable elements in message text (links,
simplex addresses, secrets), but command menus made it most visible.
A second user-visible problem surfaced while testing this fix: a click on a command was sometimes
not registered at all when clicking two commands (e.g. two /joins) in short succession. That is
defect 3 below — two independent mechanisms in the click path, unrelated to the cursor layers.
Investigation
The cursor is driven by two cooperating layers in ClickableText (TextItemView.kt):
- Detection —
Modifier.pointerInput { detectCursorMove { ... } }maps the pointer position to a character offset and checks forCOMMAND/link annotations, storingPointerIcon.HandorPointerIcon.Textin per-item state (onHover). - Display —
Modifier.pointerHoverIcon(icon.value)tells Compose which cursor to show.
Both layers were traced against the Compose Multiplatform 1.8.2 sources (ui-desktop,
foundation-desktop).
Defect 1: detection layer lost events (commit "fix pointer cursor not changing to hand…")
detectCursorMove (GestureDetector.kt) processed one pointer event per
awaitPointerEventScope block, exiting and re-entering the scope through forEachGesture for
every event. forEachGesture is deprecated in this exact Compose version with the message "Use
awaitEachGesture instead. forEachGesture() can drop events between gestures." — so the final Move
event, the one that should switch the cursor when the pointer comes to rest on a command, could be
silently dropped. It also ignored Enter events entirely, so when the chat list shifted under a
stationary cursor (every command click appends the sent message), the newly hovered item — which
receives Enter, not Move — never updated the icon state.
Fix: one never-exiting awaitPointerEventScope loop reacting to Move, Enter and Release
(plus an optional onExit callback used by the cursor fix below). Release refreshes hover at
the final pointer position — after clicking a command the list shifts and the release is the only
event a stationary pointer gets. Button-held events are ignored (gated on no pointer pressed,
matching the old forEachGesture semantics which waited for all pointers up between events), and
so are out-of-bounds positions: while a button is pressed the hit path is locked, so the pressed
node keeps receiving events after the pointer leaves it — acting on those would apply hover
effects at clamped positions (e.g. a wrong cursor stuck after drag-releasing outside the text).
The other detectCursorMove call sites (scrollbar reveal in ScrollableColumn.desktop.kt and
OperatorView.desktop.kt) were audited: reacting to Enter/Release there is harmless or an
improvement, and onExit defaults to a no-op for them.
Defect 2: display layer silently swallows updates (commit "set hover cursor directly…")
This made hovering reliable but the cursor could still get stuck, because Compose's
pointerHoverIcon display side is edge-triggered with several silent-drop guards
(all confirmed in the CMP 1.8.2 sources):
HoverIconModifierNodeacts only on per-nodeEnter/Exitevents (PointerIcon.kt:253-259); Move events never refresh the displayed cursor.- Its
iconsetter displays only if the value changed and the node is marked in-bounds (PointerIcon.kt:213-221) — re-asserting the same value, or changing it while the node believes the cursor is outside, is silently dropped. setIconwrites the AWT cursor immediately, last-write-wins, with no later reconciliation (RootNodeOwner.skiko.kt:738-744);onDetach/onCancelPointerInputreset it to default from recomposition, outside pointer dispatch (PointerIcon.kt:278-288).- Whether a node gets
Enterdepends onhasEnteredstate stored separately inHitPathTracker.Node(HitPathTracker.kt:582-594); confirmed windows exist where the two desync (detach betweenbuildCacheand dispatch;cleanUpHoverclearingisInwithout dispatching; Press/Release never remapping to Enter). Once desynced, the node receives only Moves — which it ignores — so the hand can never be displayed again until the pointer physically leaves and re-enters the text. - Recovery after content shifts under a stationary cursor depends entirely on the synthetic Move
(
SyntheticEventSender), which is skipped whenever the sender was reset orneedUpdatePointerPositionwas not set for that frame.
Clicking many commands maximizes exposure: each click inserts a message → recomposition burst +
list shift + (for /join) group-state changes — each transition can hit one of these edges.
Related upstream issues: JetBrains/compose-multiplatform #2091, #1314, #3750.
Fix (initial): in onHover — which now fires reliably on every Move/Enter/Release over the
text — set the AWT cursor imperatively on desktop (desktopSetHoverCursor, writing to the same
Skia canvas component Compose writes to), and symmetrically reset both the cursor and the icon
state on the per-node Exit event.
Removed after defect 5 was fixed at the root. The empirical justification for the imperative
layer (stage 1 under Testing) was contaminated: that test ran while defect 5 was live, i.e. while
every message insertion reset every hover handler in the viewport — which alone explains the
observed failure (a reset suspending handler restarts only on the next pointer event, leaving a
stationary cursor blind after every click). With defects 1 and 5 fixed, the full hover matrix
(rapid stationary-mouse command clicks, list shifting under a hovered pointer, fast sweeps across
commands/links/text, press-drag-out-release-re-enter, wheel-scroll over commands) passes without
the imperative layer, so it was deleted; Modifier.kt/Modifier.desktop.kt/Modifier.android.kt
are untouched by this PR again. What remains of this defect's fix is the icon.value = Text reset
on Exit in MarkdownText — without it, pointerHoverIcon re-displays a stale Hand on the next
Enter. The display-layer edges documented above are real in the sources, but with stable handlers
they are not practically reachable in these scenarios.
Residual limitation (known, strictly smaller than the fixed bug)
If a hovered clickable text leaves the composition without the pointer ever getting an Exit
event (e.g. the hovered message is deleted, or a fast fling disposes the item within a frame), the
onExit reset does not run — the coroutine is simply cancelled. The retained pointerHoverIcon
modifier's onDetach reset covers this when its node is in the normal (non-desynced) state; in the
rare desynced state the cursor may stay a hand until the next hover write. This is the same event
class the fix reduces, bounded to one stale frame region, and self-corrects on any subsequent
hover.
Defect 3: command clicks intermittently lost (commit "multiplatform: fix command clicks lost on quick successive clicks")
Clicking two commands in short succession sometimes lost a click. Two independent mechanisms, each reproduced in an isolated harness before fixing and re-verified fixed with the same harness:
-
Press-scope race in
detectGesture(GestureDetector.kt — a 2022 copy ofdetectTapGesturespredating upstream's hardening; the file has not been resynced since). The singlePressGestureScopeImplis shared across gestures, and its methods run in two different lanes of the UI thread:reset()/release()synchronously inside pointer-event dispatch, while theonPresshandler (which awaits release, then callsonClick) runs in a launched coroutine resumed through the dispatcher queue. With the old non-suspendingreset()(mutex.tryLock()), two fast clicks interleave as: up₁release()setsisReleasedand unlocks — the first click'stryAwaitReleaseresumption is only queued — then down₂reset()clearsisReleasedsynchronously, so the queued resumption readsfalseand the first click'sonClicknever fires. Reproduced deterministically in a single-threaded coroutine harness replaying this ordering (click1=false click2=true).Fix: mirror the
detectTapGesturesserialization from Compose 1.8.2 (foundationTapGestureDetector.kt):reset()issuspendand takes the mutex, so a new gesture cannot clear the flags until the previous gesture's press handler finished;release()/cancel()unlock guardedly (if (mutex.isLocked)) and are launched joining the reset job, so flag writes cannot be reordered across gestures;tryAwaitRelease()releases the mutex after acquiring it, which is what lets the nextreset()proceed. The mutex becomes a serialization token between consecutive gestures.detectGesturealso runs for Android touch, so the same quick-tap loss is fixed there. -
pointerInputrestart swallowing an in-flight click (TextItemView.kt).ClickableTextkeyedpointerInput(onClick, onLongClick)on lambdas that are new instances every recomposition, so any recomposition of the message restarted the gesture coroutine and destroyed a gesture in flight: the cancelledonPressnever resumes, and the restarted detector waits for a fresh down that never comes for the press already held. Clicking command 1 sends a message whose insertion recomposes the item 50–300 ms later — exactly when the second click tends to be pressed. Reproduced in a headlessImageComposeScenedriving the verbatim repo gesture code with synthetic pointer events: press → recomposition applies → release lost the click, while control clicks on either side registered.Fix: key both
pointerInputblocks onUnitand read the latest handlers throughrememberUpdatedState(the standard idiom for long-lived event coroutines). This also stops recompositions from cancellingdetectCursorMovemid-stream — the same event-loss class as defect 1 — and gives allClickableTextcallers latest-capture semantics forannotatedText(previously the handlers could act on a stale capture until the restart).
A theoretical residue remains: if three gestures' pointer events were all processed before the
dispatcher ran any of the second gesture's jobs, flags could still mispair. This exposure is
structurally identical in upstream Compose 1.8.2 (launchAwaitingReset joins only its own
gesture's reset job), is not reachable by human input, and fixing it would diverge from the
mirrored upstream — left as upstream parity.
Defect 4: press cancelled when the list shifts under the pointer (commit "multiplatform: don't cancel command click when chat list shifts under the pointer")
Clicks were still lost after defect 3's fixes. waitForUpOrCancellation cancels a press when an
event's position is out of the node's bounds — correct for a pointer dragged away, wrong when the
node moved out from under a stationary pointer, which is exactly what happens when the sent
command's message inserts and shifts the list during the press. In node-local coordinates the two
cases are numerically identical; reproduced in a headless harness (60px shift under a held
pointer → synthetic Exit at out-of-bounds local position → press cancelled).
Fix: the two cases separate in window coordinates. ClickableText tracks its
LayoutCoordinates (onGloballyPositioned) and passes a window-position lambda into
detectGesture; waitForUpOrCancellation exempts the out-of-bounds cancel when the pointer moved
≤ touchSlop in window space since the down. Real drag-aways (> slop) cancel as before; both
parameters default to null, reducing the check literally to the old expression for other callers.
Defect 5: every message insertion reset all pointer handlers (commit "multiplatform: stop pointer handler resets on chat item recomposition…")
The dominant residual mechanism, found by instrumenting handler lifecycle: every insertion
produced a burst of gesture-handler cancellations for all visible clickable messages — with the
composables not disposed and the handler restarting on the same node. A press in flight during
such a burst died silently (down with no release/cancel), needing a second click; the hover
handler reset the same way, causing the residual hand-cursor flicker.
Cause: ChatViewListItem provided LocalViewConfiguration.current.bigTouchSlop() — a new
anonymous ViewConfiguration instance on every recomposition of every item, and
SuspendingPointerInputModifierNodeImpl.onViewConfigurationChange() is
resetPointerInputHandler() (verified in the 1.8.2 sources). Insertions recompose all visible
items (shifting index), so each insertion reset every pointer handler in the viewport.
Fix: provide a remembered instance — remember(viewConfiguration) { viewConfiguration.bigTouchSlop() } — keyed on the parent ViewConfiguration so a real platform
change still regenerates it. Values are unchanged (the wrapper delegates); only the identity that
the reset machinery reacts to is stabilized. Verified in a harness: a press held across a
recomposition dies with the per-recomposition instance and survives with the remembered one.
Performance
detectCursorMoveis now cheaper per event than before (no scope teardown/re-entry per event); the pressed check allocates one list iterator per event.- Up to two added recompositions per exit/re-enter cycle of a Hand region (the exit reset writes
icon.valueHand→Text, making the next re-enter a Text→Hand write where pre-fix both were no-ops); otherwise the icon state logic is unchanged. Android is a no-op. - The defect 3 fix launches two extra short-lived coroutines per gesture (reset job, release/cancel
job) — negligible at click rate. Not restarting
pointerInputon every recomposition removes per-recomposition coroutine churn that existed before.
Framework line numbers cited above are from the official ui-desktop-1.8.2/foundation-desktop-1.8.2
sources jars on Maven Central; they will drift on upgrade.
Testing
Verified on Linux (AppImage), in two stages (commit subjects name the defect layer each commit fixes; the user-visible symptom needed both layers, per stage 1 below):
- A build with only the detection-layer fix (lossless
detectCursorMove) was tested first and the stale-cursor symptom still reproduced — this is the empirical justification for the imperative display-layer workaround; the declarative-only fix is not sufficient. - With both layers (pre-hardening build, commit
df6b0655d): hovering commands after clicking several in a row, clicking stacked/joincommands without moving the mouse, and sweeping across commands quickly — the hand cursor tracks correctly in all cases.
The later hardening commits (release refresh, pressed/bounds gating, exit resets, canvas cache) are verified by compilation on both targets and multi-pass adversarial review; runtime re-verification of the final build is pending.
Defect 3 is verified by the two isolated repro harnesses described above (both mechanisms
reproduced pre-fix, both register every click post-fix — including a click held across a
recomposition and two fast clicks back-to-back through real Compose event dispatch in
ImageComposeScene), by compilation, and by multi-pass adversarial review (two clean passes
pre-commit, two post-commit).
Stage 1's conclusion is retroactively invalidated by defect 5 (see defect 2's removal note): the "detection fix alone is insufficient" evidence was gathered while insertions were resetting the detection handler itself. After defect 5's fix, the imperative workaround was removed and the hover scenarios re-verified passing on the framework path alone.
Defects 4 and 5 were isolated with four rounds of temporary instrumentation in the real app (logging every gesture exit path, composable disposal, and handler/node identity — removed before commit), each mechanism reproduced and its fix verified in a headless harness, reviewed through two clean adversarial passes, and confirmed fixed by runtime testing on Linux: fast successive command clicks all register, and the residual hand-cursor flicker on message insertion is gone. Known unfixable-at-this-layer residue: a click that lands where a command was before the list shifted (pre-press shift — an aiming race, not an event-handling defect).