fix(apple/cursor): a pointer whose bitmap has not landed must not vanish
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 7m30s
apple / swift (push) Successful in 6m42s
ci / rust-arm64 (push) Successful in 10m2s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
deb / build-publish-host (push) Successful in 10m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
android / android (push) Successful in 12m14s
deb / build-publish (push) Successful in 12m47s
windows-host / package (push) Successful in 16m3s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 18m4s
deb / build-publish-client-arm64 (push) Successful in 10m45s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m17s
ci / rust (push) Successful in 28m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 33m5s
apple / screenshots (push) Successful in 24m18s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m5s
ci / bench (push) Successful in 7m30s
apple / swift (push) Successful in 6m42s
ci / rust-arm64 (push) Successful in 10m2s
decky / build-publish (push) Successful in 27s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
deb / build-publish-host (push) Successful in 10m7s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 14s
android / android (push) Successful in 12m14s
deb / build-publish (push) Successful in 12m47s
windows-host / package (push) Successful in 16m3s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 18m4s
deb / build-publish-client-arm64 (push) Successful in 10m45s
flatpak / build-publish (push) Successful in 7m12s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m25s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m17s
ci / rust (push) Successful in 28m53s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
release / apple (push) Successful in 33m5s
apple / screenshots (push) Successful in 24m18s
`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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user