From 36e566052ff8bff6302e22d037e3f176069c2644 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 14:13:08 +0200 Subject: [PATCH] fix(apple/cursor): a pointer whose bitmap has not landed must not vanish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resetCursorRects` wore the host shape only when `hostCursors[st.serial]` hit, and fell through to `invisibleCursor` on a miss. But a miss is the ROUTINE case, not a degenerate one, and it is not a reason to hide the pointer: * State (0xD0) is a per-frame datagram and announces the new serial the moment the host QUEUES the bitmap on the reliable control stream, so on every single shape change the client knows a serial before it holds the pixels. * The shape ring drops the NEWEST under burst (CURSOR_SHAPE_QUEUE = 8) and the host never re-sends it — it only sends on a serial CHANGE — so a dropped serial stays un-backed until the pointer next changes shape. Crossing a toolbar flips arrow/I-beam/hand/resize several times a second, and each flip mints a fresh serial and a fresh bitmap, so bursts are ordinary. Either way the pointer BLINKED OUT rather than lagging, and in the dropped case it stayed gone for as long as the pointer held that shape — reported on glass as "the I-beam never appears over text fields, every other cursor is fine". Hold the last worn shape across the gap: only `st.visible == false` (the host says the pointer is hidden) may hide it now, never a missing bitmap. Worst case is a briefly stale pointer. This also makes two comments that already claimed this behaviour true — the shape-rejected warning's "keeping the previous cursor" and CURSOR_SHAPE_QUEUE's healing claim, both of which were fiction. Host side is exonerated: on .221 the poller sees the I-beam handle (0x10005, CURSOR_SHOWING set), and the monochrome AND-over-XOR conversion renders a correct glyph — black beam, white outline — so the bitmap that goes on the wire is good. swift build PunktfunkKit + cargo check punktfunk-core green; on-glass owed. Co-Authored-By: Claude Opus 5 (1M context) --- .../PunktfunkKit/Views/StreamView.swift | 24 ++++++++++++++++--- crates/punktfunk-core/src/client/planes.rs | 9 +++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index feeab91b..840ecf93 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -236,6 +236,13 @@ public final class StreamLayerView: NSView { let hotY: Int } private var hostCursors: [UInt32: HostCursorShape] = [:] + /// The last shape actually worn. State (`0xD0`, a per-frame datagram) announces a new serial the + /// moment the host QUEUES its bitmap on the reliable control stream, so the client routinely + /// knows a serial before it holds the pixels — and the shape ring drops the NEWEST under burst + /// (`CURSOR_SHAPE_QUEUE`), which the host never re-sends because it only sends on a serial + /// CHANGE. Both leave `hostCursors[serial]` empty; wearing the previous pointer through that + /// gap degrades it to a briefly-stale shape instead of blinking the pointer out of existence. + private var lastWornShape: HostCursorShape? private var cursorState: PunktfunkConnection.CursorStateEvent? /// Last `CursorRenderMode.clientDraws` told to the host (the §8 mid-stream render flip); /// nil = nothing sent yet. Edge-detected by [`reconcileCursorRender`] from the live mouse @@ -509,10 +516,19 @@ public final class StreamLayerView: NSView { override public func resetCursorRects() { if captured && desktopMouse { // Cursor channel active: wear the HOST's pointer shape (it is no longer in the - // video); hidden host pointer (or no shape yet) = invisible. Without the channel, - // M1 behavior: invisible local cursor, the composited host cursor is the visible one. + // video); a HIDDEN host pointer (or nothing seen yet at all) = invisible. Without the + // channel, M1 behavior: invisible local cursor, the composited host cursor is the + // visible one. + // + // A visible pointer whose announced serial has no bitmap yet falls back to the last + // worn shape (see `lastWornShape`) rather than to `invisibleCursor`. That case is + // routine, not degenerate — state outruns its bitmap on every single shape change — + // and treating it as "hide the pointer" made the pointer VANISH over anything whose + // shape arrived late or got dropped, with no recovery until the next change. Only + // `st.visible == false` may hide the pointer; a missing bitmap may not. if cursorChannelActive, let st = cursorState, st.visible, - let shape = hostCursors[st.serial] { + let shape = hostCursors[st.serial] ?? lastWornShape { + lastWornShape = shape addCursorRect(bounds, cursor: scaledCursor(shape)) } else { addCursorRect(bounds, cursor: Self.invisibleCursor) @@ -583,6 +599,8 @@ public final class StreamLayerView: NSView { private func applyCursorShape(_ ev: PunktfunkConnection.CursorShapeEvent) { guard let shape = Self.makeShape(ev) else { + // Truthful only because `resetCursorRects` falls back to `lastWornShape`: before that, + // a rejection here left the announced serial with no bitmap and HID the pointer. streamInputLog.warning("cursor shape rejected (\(ev.width)x\(ev.height)) — keeping the previous cursor") return } diff --git a/crates/punktfunk-core/src/client/planes.rs b/crates/punktfunk-core/src/client/planes.rs index 0adccbbc..e70b2fbc 100644 --- a/crates/punktfunk-core/src/client/planes.rs +++ b/crates/punktfunk-core/src/client/planes.rs @@ -36,8 +36,13 @@ pub(crate) const HOST_TIMING_QUEUE: usize = 512; pub(crate) const CLIP_EVENT_QUEUE: usize = 32; /// Cursor-shape plane depth (control-stream [`crate::quic::CursorShape`], one per pointer-bitmap -/// change — human-paced). Overflow drops the newest (try_send); the next shape change or a -/// serial mismatch against `0xD0` state heals it visually within a shape-change period. +/// change — human-paced, but bursty: crossing a toolbar flips arrow/I-beam/hand/resize several +/// times a second, and every flip mints a fresh serial and a fresh bitmap). Overflow drops the +/// newest (try_send) and the host does NOT re-send it — it only sends on a serial CHANGE — so the +/// dropped serial stays un-backed until the pointer changes shape again. Embedders must therefore +/// hold their last worn shape when `hostCursors[serial]` misses rather than hiding the pointer +/// (the Apple client's `lastWornShape`); healing is bounded by the next shape change, not by this +/// ring. pub(crate) const CURSOR_SHAPE_QUEUE: usize = 8; /// Cursor-state plane depth (`0xD0`, one datagram per captured frame). Latest-wins state — the