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

@@ -16,7 +16,8 @@ class InputDiagnostics extends ChangeNotifier {
static final InputDiagnostics instance = InputDiagnostics._();
int frames = 0;
int scaleDropped = 0; // frames rejected as a scale glitch
int scaleDropped = 0; // frames HARD-rejected (legacy; prefer soft-clamp)
int softClamped = 0; // frames whose step was soft-clamped (still applied)
int focalDropped = 0; // frames rejected as a focal/position glitch
int rebaselines = 0; // pointer-count re-baselines
int pointerCountMax = 0;
@@ -42,13 +43,16 @@ class InputDiagnostics extends ChangeNotifier {
required double focalJumpPx,
required bool scaleDrop,
required bool focalDrop,
bool softClamped = false,
double? liveScale,
}) {
frames++;
if (scaleDrop) scaleDropped++;
if (softClamped) this.softClamped++;
if (focalDrop) focalDropped++;
if (rawScale < rawScaleMin) rawScaleMin = rawScale;
if (rawScale > rawScaleMax) rawScaleMax = rawScale;
final double resulting = currentScale * appliedChange;
final double resulting = currentScale;
if (resulting < scaleMin) scaleMin = resulting;
if (resulting > scaleMax) scaleMax = resulting;
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
@@ -56,16 +60,21 @@ class InputDiagnostics extends ChangeNotifier {
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
final liveBit = liveScale == null
? ''
: ' live=${liveScale.toStringAsFixed(3)}';
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
'ch=${appliedChange.toStringAsFixed(3)} '
'cur=${currentScale.toStringAsFixed(3)} '
'cur=${currentScale.toStringAsFixed(3)}$liveBit '
'fj=${focalJumpPx.toStringAsFixed(0)}'
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}';
'${softClamped ? " SCLAMP" : ""}'
'${scaleDrop ? " SDROP" : ""}'
'${focalDrop ? " FDROP" : ""}';
_trace.add(line);
if (_trace.length > 24) _trace.removeAt(0);
FrameSampler.instance.recordZoom(
rawScale: rawScale,
scaleDrop: scaleDrop,
scaleDrop: scaleDrop || softClamped,
focalDrop: focalDrop,
focalJumpPx: focalJumpPx,
);
@@ -74,7 +83,8 @@ class InputDiagnostics extends ChangeNotifier {
}
void reset() {
frames = scaleDropped = focalDropped = rebaselines = pointerCountMax = 0;
frames = scaleDropped = softClamped = focalDropped = rebaselines =
pointerCountMax = 0;
rawScaleMin = scaleMin = double.infinity;
rawScaleMax = scaleMax = 0;
maxFocalJumpPx = 0;
@@ -83,15 +93,15 @@ class InputDiagnostics extends ChangeNotifier {
notifyListeners();
}
String _f(double v) => v.isFinite ? v.toStringAsFixed(2) : '-';
String summary() {
if (frames == 0) return 'zoom: (pinch to record)';
return 'zoom f=$frames sDrop=$scaleDropped fDrop=$focalDropped '
'rebase=$rebaselines pMax=$pointerCountMax\n'
' raw=${_f(rawScaleMin)}..${_f(rawScaleMax)} '
'scale=${_f(scaleMin)}..${_f(scaleMax)}\n'
final rawLo = rawScaleMin.isFinite ? rawScaleMin.toStringAsFixed(2) : '-';
final rawHi = rawScaleMax > 0 ? rawScaleMax.toStringAsFixed(2) : '-';
final scLo = scaleMin.isFinite ? scaleMin.toStringAsFixed(2) : '-';
final scHi = scaleMax > 0 ? scaleMax.toStringAsFixed(2) : '-';
return 'zoom f=$frames sDrop=$scaleDropped sClamp=$softClamped '
'fDrop=$focalDropped rebase=$rebaselines pMax=$pointerCountMax\n'
' raw=$rawLo..$rawHi scale=$scLo..$scHi\n'
' maxFocalJump=${maxFocalJumpPx.toStringAsFixed(0)}px '
'maxScaleJump=${_f(maxAppliedScaleJump)}';
'maxScaleJump=${maxAppliedScaleJump.toStringAsFixed(2)}';
}
}

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;
}

View File

@@ -24,6 +24,7 @@
// because this canvas always uses an infinite boundary, free pan, and no
// rotation — so that code was provably a no-op here.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
@@ -122,6 +123,12 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
/// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.0;
/// Windows ScaleGestureRecognizer emits one onUpdate per finger move in the
/// same event-loop turn. Applying both mutates the matrix twice with an
/// intermediate state (Surface diag: √2-ish cur ping-pong). Keep latest only.
ScaleUpdateDetails? _pendingScaleUpdate;
bool _scaleFlushScheduled = false;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
@@ -169,6 +176,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
}
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_gestureType = null;
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
@@ -178,6 +187,27 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
}
void _onScaleUpdate(ScaleUpdateDetails details) {
// Pointer-count change must apply immediately (re-baseline), not coalesce.
if (details.pointerCount != _lastPointerCount) {
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_applyScaleUpdate(details);
return;
}
_pendingScaleUpdate = details;
if (_scaleFlushScheduled) return;
_scaleFlushScheduled = true;
scheduleMicrotask(() {
_scaleFlushScheduled = false;
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
if (pending != null && mounted && _scaleStart != null) {
_applyScaleUpdate(pending);
}
});
}
void _applyScaleUpdate(ScaleUpdateDetails details) {
final double scale = _transformer.value.getMaxScaleOnAxis();
_scaleAnimationFocalPoint = details.localFocalPoint;
@@ -220,14 +250,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final bool focalDrop =
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
void record(double appliedChange, bool scaleDrop, bool focalDropped) {
void record(
double currentScale,
double appliedChange,
bool softClamped,
bool focalDropped,
) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: scale,
currentScale: currentScale,
appliedChange: appliedChange,
focalJumpPx: focalJumpPx,
scaleDrop: scaleDrop,
scaleDrop: false,
softClamped: softClamped,
focalDrop: focalDropped,
);
}
@@ -252,7 +288,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_rawScaleAtBaseline = details.scale;
_lastAppliedScale = step.appliedScale;
}
record(1.0, step.spiked, true);
record(_lastAppliedScale, 1.0, step.spiked, true);
return;
}
@@ -273,7 +309,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastAppliedScale = targetScale;
record(applied, step.spiked, false);
record(targetScale, applied, step.spiked, false);
case _GestureType.pan:
assert(_referenceFocalPoint != null);
@@ -281,7 +317,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
if (details.scale != 1.0) return;
if (focalDrop) {
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, true);
record(scale, 1.0, false, true);
return;
}
final Offset translationChange =
@@ -289,11 +325,17 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_transformer.value =
_matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, false);
record(scale, 1.0, false, false);
}
}
void _onScaleEnd(ScaleEndDetails details) {
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
if (pending != null && _scaleStart != null) {
_applyScaleUpdate(pending);
}
_scaleStart = null;
_referenceFocalPoint = null;
_lastPointerCount = 0;