diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b97e3d0..a1e33a2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -220,7 +220,13 @@ jobs: HTTPS_PROXY: http://192.168.31.189:7890 http_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 shell: powershell diff --git a/lib/diagnostics/diagnostic_export.dart b/lib/diagnostics/diagnostic_export.dart index c576cae..aee1e63 100644 --- a/lib/diagnostics/diagnostic_export.dart +++ b/lib/diagnostics/diagnostic_export.dart @@ -1,5 +1,6 @@ // Build a zip diagnostic pack the user can hand back for remote debugging. +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -13,6 +14,18 @@ import 'badnote_log.dart'; import 'frame_sampler.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 { DiagnosticExportResult({required this.zipPath, required this.bytes}); @@ -34,6 +47,8 @@ class DiagnosticExport { final meta = { 'exportedAt': DateTime.now().toIso8601String(), 'sessionId': log.sessionId, + 'gitSha': kBadNoteGitSha, + 'buildTime': kBadNoteBuildTime.isEmpty ? null : kBadNoteBuildTime, 'platform': Platform.operatingSystem, 'osVersion': Platform.operatingSystemVersion, 'localHostname': Platform.localHostname, @@ -48,7 +63,8 @@ class DiagnosticExport { 'zoom': InputDiagnostics.instance.summary(), 'frames': FrameSampler.instance.summary(), '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(); diff --git a/lib/editor/canvas/input_diagnostics.dart b/lib/editor/canvas/input_diagnostics.dart index 784f0ce..2b1c855 100644 --- a/lib/editor/canvas/input_diagnostics.dart +++ b/lib/editor/canvas/input_diagnostics.dart @@ -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)}'; } } diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index f6ff9b7..714a4a8 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -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 { /// 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 { 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 { 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 { ); 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 { 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 { 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; } diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index 02dc3d0..8c3814a 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -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 /// (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 _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 } 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 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 _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 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 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 _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;