Files
BadNote/lib/editor/canvas/pen_interactive_viewer.dart
Akiba So 682907614d
All checks were successful
CI / Windows build (push) Successful in 12m36s
fix(zoom): drive pinch absolutely from gesture-start snapshot; snappier pen
Zoom flicker root cause (from the on-device badnote_input_log.txt): the pinch
computed its per-frame scale change as desiredScale / getMaxScaleOnAxis(), i.e.
it fed the LIVE matrix back into its own update. Consecutive frames in the log
show `cur` (the live read) dropping to 0.75-0.89 for a single frame while the
result track stayed smooth, so the code demanded a 1.3-1.4x correction that
popped the zoom bigger/smaller and snapped back. The >1.4 glitch guard missed it
because the spikes sat at 1.31-1.40.

Fix: the scale branch of PenInteractiveViewer now drives the transform
ABSOLUTELY from a gesture-start snapshot (_scaleStart, _referenceFocalPoint) plus
the recognizer's clean, monotonic cumulative details.scale. Each frame is fully
re-derived in closed form (pure scale+translate, no matrix inversion, no live
read-back), so a transient mis-read or interleaved write cannot survive into the
next frame. The per-frame glitch guard now keys on the recognizer's own
scale-ratio (the true finger motion) instead of the corrupted live read. 2-finger
pan still falls out of the same focal-anchor formula.

Pen feel: lower perfect_freehand streamline 0.5 -> 0.32 (new shared constants
kPenStreamline/kPenSmoothing, single-sourced across screen + export so the
parity test still holds). At 0.5 a quick flick lagged so far behind the pen that
short fast strokes collapsed toward their start and rendered as a dot
("写字识别成单击"); 0.32 tracks the real path for a crisper, lower-latency feel.

flutter analyze lib/editor clean; 66/66 tests pass (incl. screen==export parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:45:54 +08:00

489 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/editor/canvas/pen_interactive_viewer.dart
//
// A focused fork of Flutter 3.44's InteractiveViewer, adapted for the pen-first
// canvas (clean-room model shared with Saber). Two deliberate changes vs stock:
//
// 1. The pan/zoom ScaleGestureRecognizer is restricted to NON-stylus devices
// (`supportedDevices` excludes stylus / invertedStylus). The pen therefore
// never reaches this recognizer — it only draws via the canvas `Listener`.
// This removes the gesture-arena fight and, crucially, the one-frame
// "pan-steal" where a stylus stroke's first frame was consumed as a pan
// (the "写字识别成单击" feel bug) because stock InteractiveViewer's
// `panEnabled` only updated a frame after the stroke had begun.
//
// 2. The per-frame scale change is clamped (`_kMin/_MaxScaleChangePerFrame`).
// Stock InteractiveViewer already damps focal jitter and guards the pan
// branch, but a single-frame multi-touch glitch can still spike
// `details.scale`, popping the zoom bigger/smaller for one frame and then
// snapping back (the reported pinch flicker). Clamping the per-update change
// swallows that spike without affecting a real (gradual) pinch, since scale
// is tracked absolutely from gesture start and simply catches up next frame.
//
// Everything else (scale-about-focal math, pan, fling inertia, mouse-wheel zoom)
// is Flutter's proven logic. The boundary/rotation/panAxis machinery is dropped
// because this canvas always uses an infinite boundary, free pan, and no
// rotation — so that code was provably a no-op here.
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'input_diagnostics.dart';
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
/// is owned exclusively by the drawing `Listener`.
const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.unknown,
};
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
/// demanding far more than this is a Windows multi-touch position glitch, not
/// intent — that frame is dropped so the zoom can't pop and snap back.
const double _kScaleGlitchHi = 1.4;
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// During a 2-finger gesture the focal point (finger midpoint) should move
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
/// and the frame is dropped (position-jump guard).
const double _kFocalGlitchPx = 250.0;
const double _kDrag = 0.0000135;
enum _GestureType { pan, scale }
/// Pan + zoom for the pen canvas. The pen never reaches this widget's gesture
/// recognizer; only touch / mouse / trackpad pan and zoom the shared transform.
class PenInteractiveViewer extends StatefulWidget {
const PenInteractiveViewer({
super.key,
required this.transformationController,
required this.child,
this.minScale = 0.5,
this.maxScale = 8.0,
this.panEnabled = true,
this.scaleEnabled = true,
this.scaleFactor = kDefaultMouseScrollToScaleFactor,
this.interactionEndFrictionCoefficient = _kDrag,
}) : assert(minScale > 0),
assert(maxScale >= minScale);
final TransformationController transformationController;
final Widget child;
final double minScale;
final double maxScale;
final bool panEnabled;
final bool scaleEnabled;
final double scaleFactor;
final double interactionEndFrictionCoefficient;
@override
State<PenInteractiveViewer> createState() => _PenInteractiveViewerState();
}
class _PenInteractiveViewerState extends State<PenInteractiveViewer>
with TickerProviderStateMixin {
TransformationController get _transformer => widget.transformationController;
final GlobalKey _childKey = GlobalKey();
Animation<Offset>? _animation;
Animation<double>? _scaleAnimation;
late Offset _scaleAnimationFocalPoint;
late AnimationController _controller;
late AnimationController _scaleController;
Offset? _referenceFocalPoint;
double? _scaleStart;
_GestureType? _gestureType;
/// Number of pointers in the active gesture. When it changes (a finger lands
/// or lifts, or a Windows touch dropout/re-acquire), we re-baseline instead of
/// applying a frame whose scale/focal still refer to the old finger set.
int _lastPointerCount = 0;
/// The recognizer's cumulative `details.scale` and the absolute scale we last
/// APPLIED, both as of the previous accepted frame. The pinch is driven
/// absolutely from these + the gesture-start snapshot — we never read the live
/// matrix back into the per-frame scale change. (Re-reading
/// `getMaxScaleOnAxis()` per frame was the flicker source: a single transient
/// mis-read/interleaved write made `desiredScale/liveScale` demand a ~1.31.4x
/// jump for one frame and snap back. The glitch guard missed it because the
/// spike sat just under the 1.4 threshold.)
double _lastRawScale = 1.0;
double _lastAppliedScale = 1.0;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
if (translation == Offset.zero) return matrix.clone();
return matrix.clone()
..translateByDouble(translation.dx, translation.dy, 0, 1);
}
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
if (scale == 1.0) return matrix.clone();
assert(scale != 0.0);
final double currentScale = _transformer.value.getMaxScaleOnAxis();
final double clampedTotalScale = clampDouble(
currentScale * scale,
widget.minScale,
widget.maxScale,
);
final double clampedScale = clampedTotalScale / currentScale;
return matrix.clone()
..scaleByDouble(clampedScale, clampedScale, clampedScale, 1);
}
bool _gestureIsSupported(_GestureType? gestureType) => switch (gestureType) {
_GestureType.scale => widget.scaleEnabled,
_GestureType.pan || null => widget.panEnabled,
};
_GestureType _getGestureType(ScaleUpdateDetails details) {
final double scale = widget.scaleEnabled ? details.scale : 1.0;
return (scale - 1).abs() > 0 ? _GestureType.scale : _GestureType.pan;
}
// --- Gesture lifecycle ----------------------------------------------------
void _onScaleStart(ScaleStartDetails details) {
if (_controller.isAnimating) {
_controller.stop();
_controller.reset();
_animation?.removeListener(_handleInertiaAnimation);
_animation = null;
}
if (_scaleController.isAnimating) {
_scaleController.stop();
_scaleController.reset();
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
}
_gestureType = null;
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = 1.0;
_lastAppliedScale = _scaleStart!;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
final double scale = _transformer.value.getMaxScaleOnAxis();
_scaleAnimationFocalPoint = details.localFocalPoint;
// Re-baseline on any pointer-count change so a finger landing/lifting (or a
// Windows touch dropout) can't make scale/focal jump from the stale set.
// The transitional frame itself is skipped.
if (details.pointerCount != _lastPointerCount) {
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
_lastAppliedScale = _scaleStart!;
InputDiagnostics.instance.recordRebaseline();
return;
}
final double focalJumpPx = details.focalPointDelta.distance;
final Offset focalPointScene = _transformer.toScene(details.localFocalPoint);
if (_gestureType == _GestureType.pan) {
// A 2-finger gesture can start with no scale change; allow re-typing it.
_gestureType = _getGestureType(details);
} else {
_gestureType ??= _getGestureType(details);
}
if (!_gestureIsSupported(_gestureType)) return;
// Position-jump guard: during a pinch the focal midpoint should move
// smoothly; a big single-frame jump is a touch misread → drop the frame.
final bool focalDrop =
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
void record(double appliedChange, bool scaleDrop, bool focalDropped) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: scale,
appliedChange: appliedChange,
focalJumpPx: focalJumpPx,
scaleDrop: scaleDrop,
focalDrop: focalDropped,
);
}
switch (_gestureType!) {
case _GestureType.scale:
assert(_scaleStart != null);
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
// scale — the clean, monotonic signal (verified against device logs).
// Crucially we do NOT divide by the live matrix scale here: feeding
// getMaxScaleOnAxis() back in is what let a single mis-read pop the zoom
// and snap back. A ratio outside the glitch band is a real multi-touch
// spike → drop the frame; absolute tracking means the next good frame
// resumes from the true finger span, so the spike never shows.
final double rawRatio =
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
final bool scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
if (scaleDrop || focalDrop) {
record(1.0, scaleDrop, focalDrop);
return;
}
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
// scene point that was under the focal at gesture start stays under the
// CURRENT focal (which also yields 2-finger pan for free). Closed form
// for a pure scale+translate matrix — no inversion, no live read-back —
// so an interleaved/transient matrix write can't survive into the next
// frame: every frame is fully re-derived from clean inputs.
final double targetScale = clampDouble(
_scaleStart! * details.scale,
widget.minScale,
widget.maxScale,
);
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
_transformer.value = Matrix4.identity()
..setEntry(0, 0, targetScale)
..setEntry(1, 1, targetScale)
..setEntry(2, 2, targetScale)
..setTranslationRaw(tx, ty, 0);
final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastRawScale = details.scale;
_lastAppliedScale = targetScale;
record(applied, false, false);
case _GestureType.pan:
assert(_referenceFocalPoint != null);
// Throw away near-scale frames so a stale reference can't jump the pan.
if (details.scale != 1.0) return;
if (focalDrop) {
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, true);
return;
}
final Offset translationChange =
focalPointScene - _referenceFocalPoint!;
_transformer.value =
_matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, false);
}
}
void _onScaleEnd(ScaleEndDetails details) {
_scaleStart = null;
_referenceFocalPoint = null;
_lastPointerCount = 0;
_animation?.removeListener(_handleInertiaAnimation);
_scaleAnimation?.removeListener(_handleScaleAnimation);
_controller.reset();
_scaleController.reset();
if (!_gestureIsSupported(_gestureType)) return;
switch (_gestureType) {
case _GestureType.pan:
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
return;
}
final translationVector = _transformer.value.getTranslation();
final Offset translation =
Offset(translationVector.x, translationVector.y);
final FrictionSimulation frictionSimulationX = FrictionSimulation(
widget.interactionEndFrictionCoefficient,
translation.dx,
details.velocity.pixelsPerSecond.dx,
);
final FrictionSimulation frictionSimulationY = FrictionSimulation(
widget.interactionEndFrictionCoefficient,
translation.dy,
details.velocity.pixelsPerSecond.dy,
);
final double tFinal = _getFinalTime(
details.velocity.pixelsPerSecond.distance,
widget.interactionEndFrictionCoefficient,
);
_animation = Tween<Offset>(
begin: translation,
end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
).animate(CurvedAnimation(parent: _controller, curve: Curves.decelerate));
_controller.duration = Duration(milliseconds: (tFinal * 1000).round());
_animation!.addListener(_handleInertiaAnimation);
_controller.forward();
case _GestureType.scale:
if (details.scaleVelocity.abs() < 0.1) return;
final double scale = _transformer.value.getMaxScaleOnAxis();
final FrictionSimulation frictionSimulation = FrictionSimulation(
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
scale,
details.scaleVelocity / 10,
);
final double tFinal = _getFinalTime(
details.scaleVelocity.abs(),
widget.interactionEndFrictionCoefficient,
effectivelyMotionless: 0.1,
);
_scaleAnimation = Tween<double>(
begin: scale,
end: frictionSimulation.x(tFinal),
).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
_scaleAnimation!.addListener(_handleScaleAnimation);
_scaleController.forward();
case null:
break;
}
}
// --- Mouse wheel / trackpad zoom ------------------------------------------
void _receivedPointerSignal(PointerSignalEvent event) {
final double scaleChange;
if (event is PointerScrollEvent) {
if (event.kind == PointerDeviceKind.trackpad) {
// Trackpad scroll → pan.
if (!_gestureIsSupported(_GestureType.pan)) return;
final Offset localDelta = PointerEvent.transformDeltaViaPositions(
untransformedEndPosition: event.position + event.scrollDelta,
untransformedDelta: event.scrollDelta,
transform: event.transform,
);
final Offset focalPointScene = _transformer.toScene(event.localPosition);
final Offset newFocalPointScene =
_transformer.toScene(event.localPosition - localDelta);
_transformer.value = _matrixTranslate(
_transformer.value,
newFocalPointScene - focalPointScene,
);
return;
}
if (event.scrollDelta.dy == 0.0) return;
scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor);
} else if (event is PointerScaleEvent) {
scaleChange = event.scale;
} else {
return;
}
if (!_gestureIsSupported(_GestureType.scale)) return;
final Offset focalPointScene = _transformer.toScene(event.localPosition);
_transformer.value = _matrixScale(_transformer.value, scaleChange);
final Offset focalPointSceneScaled =
_transformer.toScene(event.localPosition);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - focalPointScene,
);
}
void _handleInertiaAnimation() {
if (!_controller.isAnimating) {
_animation?.removeListener(_handleInertiaAnimation);
_animation = null;
_controller.reset();
return;
}
final translationVector = _transformer.value.getTranslation();
final Offset translation = Offset(translationVector.x, translationVector.y);
_transformer.value = _matrixTranslate(
_transformer.value,
_transformer.toScene(_animation!.value) - _transformer.toScene(translation),
);
}
void _handleScaleAnimation() {
if (!_scaleController.isAnimating) {
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
_scaleController.reset();
return;
}
final double desiredScale = _scaleAnimation!.value;
final double scaleChange =
desiredScale / _transformer.value.getMaxScaleOnAxis();
final Offset referenceFocalPoint =
_transformer.toScene(_scaleAnimationFocalPoint);
_transformer.value = _matrixScale(_transformer.value, scaleChange);
final Offset focalPointSceneScaled =
_transformer.toScene(_scaleAnimationFocalPoint);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - referenceFocalPoint,
);
}
void _handleTransformation() => setState(() {});
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_scaleController = AnimationController(vsync: this);
_transformer.addListener(_handleTransformation);
}
@override
void didUpdateWidget(PenInteractiveViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.transformationController != widget.transformationController) {
oldWidget.transformationController.removeListener(_handleTransformation);
widget.transformationController.addListener(_handleTransformation);
}
}
@override
void dispose() {
_controller.dispose();
_scaleController.dispose();
_transformer.removeListener(_handleTransformation);
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = Transform(
transform: _transformer.value,
child: KeyedSubtree(key: _childKey, child: widget.child),
);
child = OverflowBox(
alignment: Alignment.topLeft,
minWidth: 0.0,
minHeight: 0.0,
maxWidth: double.infinity,
maxHeight: double.infinity,
child: child,
);
child = ClipRect(child: child);
return Listener(
onPointerSignal: _receivedPointerSignal,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
supportedDevices: _kPanZoomDevices,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
trackpadScrollCausesScale: false,
trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
child: child,
),
);
}
}
double _getFinalTime(double velocity, double drag,
{double effectivelyMotionless = 10}) {
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
}