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

@@ -220,7 +220,13 @@ jobs:
HTTPS_PROXY: http://192.168.31.189:7890 HTTPS_PROXY: http://192.168.31.189:7890
http_proxy: http://192.168.31.189:7890 http_proxy: http://192.168.31.189:7890
https_proxy: http://192.168.31.189:7890 https_proxy: http://192.168.31.189:7890
run: flutter build windows --release shell: powershell
run: |
$sha = if ($env:GITHUB_SHA) { $env:GITHUB_SHA.Substring(0, [Math]::Min(12, $env:GITHUB_SHA.Length)) } else { "unknown" }
$built = Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"
flutter build windows --release `
--dart-define="BADNOTE_GIT_SHA=$sha" `
--dart-define="BADNOTE_BUILD_TIME=$built"
- name: Show build output - name: Show build output
shell: powershell shell: powershell

View File

@@ -1,5 +1,6 @@
// Build a zip diagnostic pack the user can hand back for remote debugging. // Build a zip diagnostic pack the user can hand back for remote debugging.
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -13,6 +14,18 @@ import 'badnote_log.dart';
import 'frame_sampler.dart'; import 'frame_sampler.dart';
import 'pen_event_ring.dart'; import 'pen_event_ring.dart';
/// Injected at build/export time so Surface packages can be matched to git.
/// Override via `--dart-define=BADNOTE_GIT_SHA=...` in CI.
const String kBadNoteGitSha = String.fromEnvironment(
'BADNOTE_GIT_SHA',
defaultValue: 'dev',
);
const String kBadNoteBuildTime = String.fromEnvironment(
'BADNOTE_BUILD_TIME',
defaultValue: '',
);
class DiagnosticExportResult { class DiagnosticExportResult {
DiagnosticExportResult({required this.zipPath, required this.bytes}); DiagnosticExportResult({required this.zipPath, required this.bytes});
@@ -34,6 +47,8 @@ class DiagnosticExport {
final meta = <String, Object?>{ final meta = <String, Object?>{
'exportedAt': DateTime.now().toIso8601String(), 'exportedAt': DateTime.now().toIso8601String(),
'sessionId': log.sessionId, 'sessionId': log.sessionId,
'gitSha': kBadNoteGitSha,
'buildTime': kBadNoteBuildTime.isEmpty ? null : kBadNoteBuildTime,
'platform': Platform.operatingSystem, 'platform': Platform.operatingSystem,
'osVersion': Platform.operatingSystemVersion, 'osVersion': Platform.operatingSystemVersion,
'localHostname': Platform.localHostname, 'localHostname': Platform.localHostname,
@@ -48,7 +63,8 @@ class DiagnosticExport {
'zoom': InputDiagnostics.instance.summary(), 'zoom': InputDiagnostics.instance.summary(),
'frames': FrameSampler.instance.summary(), 'frames': FrameSampler.instance.summary(),
'instructions': 'instructions':
'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip.', 'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip. '
'Confirm meta.gitSha matches the CI commit you installed.',
}; };
final archive = Archive(); final archive = Archive();

View File

@@ -16,7 +16,8 @@ class InputDiagnostics extends ChangeNotifier {
static final InputDiagnostics instance = InputDiagnostics._(); static final InputDiagnostics instance = InputDiagnostics._();
int frames = 0; 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 focalDropped = 0; // frames rejected as a focal/position glitch
int rebaselines = 0; // pointer-count re-baselines int rebaselines = 0; // pointer-count re-baselines
int pointerCountMax = 0; int pointerCountMax = 0;
@@ -42,13 +43,16 @@ class InputDiagnostics extends ChangeNotifier {
required double focalJumpPx, required double focalJumpPx,
required bool scaleDrop, required bool scaleDrop,
required bool focalDrop, required bool focalDrop,
bool softClamped = false,
double? liveScale,
}) { }) {
frames++; frames++;
if (scaleDrop) scaleDropped++; if (scaleDrop) scaleDropped++;
if (softClamped) this.softClamped++;
if (focalDrop) focalDropped++; if (focalDrop) focalDropped++;
if (rawScale < rawScaleMin) rawScaleMin = rawScale; if (rawScale < rawScaleMin) rawScaleMin = rawScale;
if (rawScale > rawScaleMax) rawScaleMax = rawScale; if (rawScale > rawScaleMax) rawScaleMax = rawScale;
final double resulting = currentScale * appliedChange; final double resulting = currentScale;
if (resulting < scaleMin) scaleMin = resulting; if (resulting < scaleMin) scaleMin = resulting;
if (resulting > scaleMax) scaleMax = resulting; if (resulting > scaleMax) scaleMax = resulting;
if (pointerCount > pointerCountMax) pointerCountMax = pointerCount; if (pointerCount > pointerCountMax) pointerCountMax = pointerCount;
@@ -56,16 +60,21 @@ class InputDiagnostics extends ChangeNotifier {
final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange; final double jump = appliedChange >= 1 ? appliedChange : 1 / appliedChange;
if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump; if (jump > maxAppliedScaleJump) maxAppliedScaleJump = jump;
final liveBit = liveScale == null
? ''
: ' live=${liveScale.toStringAsFixed(3)}';
final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} ' final String line = 'p$pointerCount raw=${rawScale.toStringAsFixed(3)} '
'ch=${appliedChange.toStringAsFixed(3)} ' 'ch=${appliedChange.toStringAsFixed(3)} '
'cur=${currentScale.toStringAsFixed(3)} ' 'cur=${currentScale.toStringAsFixed(3)}$liveBit '
'fj=${focalJumpPx.toStringAsFixed(0)}' 'fj=${focalJumpPx.toStringAsFixed(0)}'
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}'; '${softClamped ? " SCLAMP" : ""}'
'${scaleDrop ? " SDROP" : ""}'
'${focalDrop ? " FDROP" : ""}';
_trace.add(line); _trace.add(line);
if (_trace.length > 24) _trace.removeAt(0); if (_trace.length > 24) _trace.removeAt(0);
FrameSampler.instance.recordZoom( FrameSampler.instance.recordZoom(
rawScale: rawScale, rawScale: rawScale,
scaleDrop: scaleDrop, scaleDrop: scaleDrop || softClamped,
focalDrop: focalDrop, focalDrop: focalDrop,
focalJumpPx: focalJumpPx, focalJumpPx: focalJumpPx,
); );
@@ -74,7 +83,8 @@ class InputDiagnostics extends ChangeNotifier {
} }
void reset() { void reset() {
frames = scaleDropped = focalDropped = rebaselines = pointerCountMax = 0; frames = scaleDropped = softClamped = focalDropped = rebaselines =
pointerCountMax = 0;
rawScaleMin = scaleMin = double.infinity; rawScaleMin = scaleMin = double.infinity;
rawScaleMax = scaleMax = 0; rawScaleMax = scaleMax = 0;
maxFocalJumpPx = 0; maxFocalJumpPx = 0;
@@ -83,15 +93,15 @@ class InputDiagnostics extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
String _f(double v) => v.isFinite ? v.toStringAsFixed(2) : '-';
String summary() { String summary() {
if (frames == 0) return 'zoom: (pinch to record)'; final rawLo = rawScaleMin.isFinite ? rawScaleMin.toStringAsFixed(2) : '-';
return 'zoom f=$frames sDrop=$scaleDropped fDrop=$focalDropped ' final rawHi = rawScaleMax > 0 ? rawScaleMax.toStringAsFixed(2) : '-';
'rebase=$rebaselines pMax=$pointerCountMax\n' final scLo = scaleMin.isFinite ? scaleMin.toStringAsFixed(2) : '-';
' raw=${_f(rawScaleMin)}..${_f(rawScaleMax)} ' final scHi = scaleMax > 0 ? scaleMax.toStringAsFixed(2) : '-';
'scale=${_f(scaleMin)}..${_f(scaleMax)}\n' 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 ' ' 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 // 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). // survive reopen, and a stored highlight can be removed (the un-highlight tool).
import 'dart:async';
import 'package:flutter/foundation.dart' import 'package:flutter/foundation.dart'
show ValueListenable, visibleForTesting; show ValueListenable, visibleForTesting;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -188,6 +190,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// absolute target normalizes against it (see [absolutePinchScale]). /// absolute target normalizes against it (see [absolutePinchScale]).
double _pinchRawScaleAtBaseline = 1.0; 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) ──────────────────────────── // ── Live stroke state (viewer-level pen capture) ────────────────────────────
/// The page index the in-progress stroke belongs to (the page of its first /// 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) { void _onPinchStart(ScaleStartDetails details) {
if (!_controller.isReady) return; 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; _pinchPointerCount = details.pointerCount;
_pinchLastAppliedScale = _pinchScaleStart!; _pinchLastAppliedScale = _pinchScaleStart!;
_pinchRawScaleAtBaseline = 1.0; _pinchRawScaleAtBaseline = 1.0;
} }
void _onPinchUpdate(ScaleUpdateDetails details) { 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; final scaleStart = _pinchScaleStart;
if (scaleStart == null || !_controller.isReady) return; 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) { if (details.pointerCount != _pinchPointerCount) {
_pinchPointerCount = details.pointerCount; _pinchPointerCount = details.pointerCount;
_pinchScaleStart = _pinchLastAppliedScale; _pinchScaleStart = _pinchLastAppliedScale;
@@ -928,8 +963,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return; 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( final step = softClampedPinchStep(
scaleStart: _pinchScaleStart!, scaleStart: _pinchScaleStart!,
rawScaleAtBaseline: _pinchRawScaleAtBaseline, rawScaleAtBaseline: _pinchRawScaleAtBaseline,
@@ -941,7 +974,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
); );
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx; final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
if (focalDrop) { if (focalDrop) {
// Keep scale continuous; only skip the focal jump this frame.
if (step.reanchor) { if (step.reanchor) {
_pinchScaleStart = step.appliedScale; _pinchScaleStart = step.appliedScale;
_pinchRawScaleAtBaseline = details.scale; _pinchRawScaleAtBaseline = details.scale;
@@ -953,7 +985,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
currentScale: _pinchLastAppliedScale, currentScale: _pinchLastAppliedScale,
appliedChange: 1.0, appliedChange: 1.0,
focalJumpPx: details.focalPointDelta.distance, focalJumpPx: details.focalPointDelta.distance,
scaleDrop: step.spiked, scaleDrop: false,
softClamped: step.spiked,
focalDrop: true, focalDrop: true,
); );
return; return;
@@ -971,28 +1004,31 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
duration: Duration.zero, 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 final applied = _pinchLastAppliedScale > 0
? appliedScale / _pinchLastAppliedScale ? targetScale / _pinchLastAppliedScale
: 1.0; : 1.0;
InputDiagnostics.instance.recordScaleFrame( InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale, rawScale: details.scale,
pointerCount: details.pointerCount, pointerCount: details.pointerCount,
currentScale: appliedScale, currentScale: targetScale,
appliedChange: applied, appliedChange: applied,
focalJumpPx: details.focalPointDelta.distance, focalJumpPx: details.focalPointDelta.distance,
scaleDrop: step.spiked, scaleDrop: false,
softClamped: step.spiked,
focalDrop: false, focalDrop: false,
liveScale: _controller.currentZoom,
); );
_pinchLastAppliedScale = appliedScale; _pinchLastAppliedScale = targetScale;
} }
void _onPinchEnd(ScaleEndDetails details) { void _onPinchEnd(ScaleEndDetails details) {
final pending = _pendingPinchUpdate;
_pendingPinchUpdate = null;
_pinchFlushScheduled = false;
if (pending != null && _pinchScaleStart != null) {
_applyPinchUpdate(pending);
}
_pinchScaleStart = null; _pinchScaleStart = null;
_pinchPointerCount = 0; _pinchPointerCount = 0;
} }

View File

@@ -24,6 +24,7 @@
// because this canvas always uses an infinite boundary, free pan, and no // because this canvas always uses an infinite boundary, free pan, and no
// rotation — so that code was provably a no-op here. // rotation — so that code was provably a no-op here.
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble; 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. /// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.0; 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) ----------- // --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) { Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
@@ -169,6 +176,8 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_scaleAnimation?.removeListener(_handleScaleAnimation); _scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null; _scaleAnimation = null;
} }
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
_gestureType = null; _gestureType = null;
_lastPointerCount = details.pointerCount; _lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis(); _scaleStart = _transformer.value.getMaxScaleOnAxis();
@@ -178,6 +187,27 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
} }
void _onScaleUpdate(ScaleUpdateDetails details) { 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(); final double scale = _transformer.value.getMaxScaleOnAxis();
_scaleAnimationFocalPoint = details.localFocalPoint; _scaleAnimationFocalPoint = details.localFocalPoint;
@@ -220,14 +250,20 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final bool focalDrop = final bool focalDrop =
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx; 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( InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale, rawScale: details.scale,
pointerCount: details.pointerCount, pointerCount: details.pointerCount,
currentScale: scale, currentScale: currentScale,
appliedChange: appliedChange, appliedChange: appliedChange,
focalJumpPx: focalJumpPx, focalJumpPx: focalJumpPx,
scaleDrop: scaleDrop, scaleDrop: false,
softClamped: softClamped,
focalDrop: focalDropped, focalDrop: focalDropped,
); );
} }
@@ -252,7 +288,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_rawScaleAtBaseline = details.scale; _rawScaleAtBaseline = details.scale;
_lastAppliedScale = step.appliedScale; _lastAppliedScale = step.appliedScale;
} }
record(1.0, step.spiked, true); record(_lastAppliedScale, 1.0, step.spiked, true);
return; return;
} }
@@ -273,7 +309,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
final double applied = final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0; _lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastAppliedScale = targetScale; _lastAppliedScale = targetScale;
record(applied, step.spiked, false); record(targetScale, applied, step.spiked, false);
case _GestureType.pan: case _GestureType.pan:
assert(_referenceFocalPoint != null); assert(_referenceFocalPoint != null);
@@ -281,7 +317,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
if (details.scale != 1.0) return; if (details.scale != 1.0) return;
if (focalDrop) { if (focalDrop) {
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint); _referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, true); record(scale, 1.0, false, true);
return; return;
} }
final Offset translationChange = final Offset translationChange =
@@ -289,11 +325,17 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_transformer.value = _transformer.value =
_matrixTranslate(_transformer.value, translationChange); _matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint); _referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, false); record(scale, 1.0, false, false);
} }
} }
void _onScaleEnd(ScaleEndDetails details) { void _onScaleEnd(ScaleEndDetails details) {
final pending = _pendingScaleUpdate;
_pendingScaleUpdate = null;
_scaleFlushScheduled = false;
if (pending != null && _scaleStart != null) {
_applyScaleUpdate(pending);
}
_scaleStart = null; _scaleStart = null;
_referenceFocalPoint = null; _referenceFocalPoint = null;
_lastPointerCount = 0; _lastPointerCount = 0;