fix: coalesce pinch updates and stop live zoom write-back
All checks were successful
CI / Windows build (push) Successful in 9m55s
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:
@@ -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)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user