fix: coalesce pinch updates and stop live zoom write-back
All checks were successful
CI / Windows build (push) Successful in 9m55s

Surface Aug6 diag showed sDrop=0 but ~220 same-ms dual ZOOM frames and √2 cur ping-pong from reading currentZoom back into pinch state. Flush once per microtask and embed gitSha in diagnostic meta.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 03:44:10 +08:00
parent 4f6fb69dee
commit 85af037b7d
5 changed files with 150 additions and 40 deletions

View File

@@ -22,6 +22,8 @@
// IS the identity; the old djb2 path-hash document id is gone). Highlights now
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
import 'dart:async';
import 'package:flutter/foundation.dart'
show ValueListenable, visibleForTesting;
import 'package:flutter/gestures.dart';
@@ -188,6 +190,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// absolute target normalizes against it (see [absolutePinchScale]).
double _pinchRawScaleAtBaseline = 1.0;
/// Latest ScaleUpdate pending coalesce (Windows fires one update per finger
/// move → two onUpdates in the same event turn; applying both causes √2-ish
/// zoom ping-pong via intermediate matrices).
ScaleUpdateDetails? _pendingPinchUpdate;
bool _pinchFlushScheduled = false;
// ── Live stroke state (viewer-level pen capture) ────────────────────────────
/// The page index the in-progress stroke belongs to (the page of its first
@@ -907,19 +915,46 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _onPinchStart(ScaleStartDetails details) {
if (!_controller.isReady) return;
_pinchScaleStart = _controller.currentZoom;
_pendingPinchUpdate = null;
_pinchFlushScheduled = false;
// Seed ONLY at gesture start. Never mid-gesture (live read-back caused the
// Aug6 √2 cur ping-pong when dual finger updates interleaved).
final live = _controller.currentZoom;
_pinchScaleStart = live > 0 ? live : 1.0;
_pinchPointerCount = details.pointerCount;
_pinchLastAppliedScale = _pinchScaleStart!;
_pinchRawScaleAtBaseline = 1.0;
}
void _onPinchUpdate(ScaleUpdateDetails details) {
if (_pinchScaleStart == null || !_controller.isReady) return;
// Pointer-count change must apply immediately (re-baseline), not coalesce.
if (details.pointerCount != _pinchPointerCount) {
_pendingPinchUpdate = null;
_pinchFlushScheduled = false;
_applyPinchUpdate(details);
return;
}
// Coalesce: Windows ScaleGestureRecognizer fires one onUpdate per finger
// move → two applies in the same turn with an intermediate matrix → √2-ish
// zoom bounce. Keep only the latest details and flush once per microtask.
_pendingPinchUpdate = details;
if (_pinchFlushScheduled) return;
_pinchFlushScheduled = true;
scheduleMicrotask(() {
_pinchFlushScheduled = false;
final pending = _pendingPinchUpdate;
_pendingPinchUpdate = null;
if (pending != null && mounted && _pinchScaleStart != null) {
_applyPinchUpdate(pending);
}
});
}
void _applyPinchUpdate(ScaleUpdateDetails details) {
final scaleStart = _pinchScaleStart;
if (scaleStart == null || !_controller.isReady) return;
// Re-baseline on any pointer-count change (a finger lands/lifts, or a
// Windows touch 2↔1↔2 dropout). Anchor to the CLEAN tracked scale, not a
// matrix read-back, so the displayed scale is continuous; skip this frame.
if (details.pointerCount != _pinchPointerCount) {
_pinchPointerCount = details.pointerCount;
_pinchScaleStart = _pinchLastAppliedScale;
@@ -928,8 +963,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return;
}
// Soft-clamp per-step change (Surface diag: hard SDROP avalanche when
// lastRaw froze while live zoom still crawled). Always apply + advance.
final step = softClampedPinchStep(
scaleStart: _pinchScaleStart!,
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
@@ -941,7 +974,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
);
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
if (focalDrop) {
// Keep scale continuous; only skip the focal jump this frame.
if (step.reanchor) {
_pinchScaleStart = step.appliedScale;
_pinchRawScaleAtBaseline = details.scale;
@@ -953,7 +985,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
currentScale: _pinchLastAppliedScale,
appliedChange: 1.0,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: step.spiked,
scaleDrop: false,
softClamped: step.spiked,
focalDrop: true,
);
return;
@@ -971,28 +1004,31 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
duration: Duration.zero,
);
// Prefer controller read-back so we stay locked to what pdfrx actually
// applied (guards against a second consumer nudging zoom).
final live = _controller.currentZoom;
final appliedScale = live > 0 ? live : targetScale;
final applied = _pinchLastAppliedScale > 0
? appliedScale / _pinchLastAppliedScale
? targetScale / _pinchLastAppliedScale
: 1.0;
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: appliedScale,
currentScale: targetScale,
appliedChange: applied,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: step.spiked,
scaleDrop: false,
softClamped: step.spiked,
focalDrop: false,
liveScale: _controller.currentZoom,
);
_pinchLastAppliedScale = appliedScale;
_pinchLastAppliedScale = targetScale;
}
void _onPinchEnd(ScaleEndDetails details) {
final pending = _pendingPinchUpdate;
_pendingPinchUpdate = null;
_pinchFlushScheduled = false;
if (pending != null && _pinchScaleStart != null) {
_applyPinchUpdate(pending);
}
_pinchScaleStart = null;
_pinchPointerCount = 0;
}