feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
// 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';
|
|
|
|
|
|
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
import 'input_diagnostics.dart';
|
2026-06-23 09:30:41 +08:00
|
|
|
|
import 'pinch_scale_solver.dart';
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
/// 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-22 22:02:17 +08:00
|
|
|
|
/// 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;
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.
Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
|
|
|
|
/// 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;
|
|
|
|
|
|
|
2026-06-23 01:45:54 +08:00
|
|
|
|
/// 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.3–1.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;
|
|
|
|
|
|
|
2026-06-23 09:30:41 +08:00
|
|
|
|
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
|
|
|
|
|
|
/// gesture start, or the last pointer-count re-baseline). The absolute target
|
|
|
|
|
|
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
|
|
|
|
|
|
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
|
|
|
|
|
|
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
|
|
|
|
|
|
/// its un-normalized cumulative value, so the next frame computed
|
|
|
|
|
|
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
|
|
|
|
|
|
/// (the reported flicker). Normalizing kills that pop at the source.
|
|
|
|
|
|
double _rawScaleAtBaseline = 1.0;
|
|
|
|
|
|
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
// --- 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;
|
fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.
Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
|
|
|
|
_lastPointerCount = details.pointerCount;
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
_scaleStart = _transformer.value.getMaxScaleOnAxis();
|
|
|
|
|
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
2026-06-23 01:45:54 +08:00
|
|
|
|
_lastRawScale = 1.0;
|
|
|
|
|
|
_lastAppliedScale = _scaleStart!;
|
2026-06-23 09:30:41 +08:00
|
|
|
|
_rawScaleAtBaseline = 1.0;
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onScaleUpdate(ScaleUpdateDetails details) {
|
|
|
|
|
|
final double scale = _transformer.value.getMaxScaleOnAxis();
|
|
|
|
|
|
_scaleAnimationFocalPoint = details.localFocalPoint;
|
fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.
Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-06-23 16:52:14 +08:00
|
|
|
|
// Anchor the new baseline to the CLEAN tracked scale (_lastAppliedScale),
|
|
|
|
|
|
// NOT a fresh matrix read-back. Windows touch flickers the pointer count
|
|
|
|
|
|
// (2↔1↔2) mid-pinch, firing this re-baseline spuriously; reading
|
|
|
|
|
|
// getMaxScaleOnAxis() at that glitchy instant popped _scaleStart to a
|
|
|
|
|
|
// noisy value, so the absolute map K = scaleStart / rawScaleAtBaseline
|
|
|
|
|
|
// oscillated frame-to-frame (the reported "zoom jump"). Using
|
|
|
|
|
|
// _lastAppliedScale makes the displayed scale CONTINUOUS across the
|
|
|
|
|
|
// re-baseline: target == _lastAppliedScale at this instant, regardless of
|
|
|
|
|
|
// any transient in the live matrix.
|
|
|
|
|
|
_scaleStart = _lastAppliedScale;
|
fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.
Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
|
|
|
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
2026-06-23 01:45:54 +08:00
|
|
|
|
_lastRawScale = details.scale;
|
2026-06-23 16:52:14 +08:00
|
|
|
|
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
|
|
|
|
|
|
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
|
2026-06-23 09:30:41 +08:00
|
|
|
|
_rawScaleAtBaseline = details.scale;
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
InputDiagnostics.instance.recordRebaseline();
|
fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.
Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
final double focalJumpPx = details.focalPointDelta.distance;
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
// 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,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
switch (_gestureType!) {
|
|
|
|
|
|
case _GestureType.scale:
|
|
|
|
|
|
assert(_scaleStart != null);
|
2026-06-23 01:45:54 +08:00
|
|
|
|
// 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;
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
final bool scaleDrop =
|
2026-06-23 01:45:54 +08:00
|
|
|
|
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
if (scaleDrop || focalDrop) {
|
|
|
|
|
|
record(1.0, scaleDrop, focalDrop);
|
2026-06-22 22:02:17 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
|
2026-06-23 01:45:54 +08:00
|
|
|
|
// 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.
|
2026-06-23 09:30:41 +08:00
|
|
|
|
// Absolute target scale, normalized against the baseline so a
|
|
|
|
|
|
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
|
|
|
|
|
|
// pinch_scale_solver.dart for the full rationale.
|
|
|
|
|
|
final double targetScale = absolutePinchScale(
|
|
|
|
|
|
scaleStart: _scaleStart!,
|
|
|
|
|
|
rawScaleAtBaseline: _rawScaleAtBaseline,
|
|
|
|
|
|
rawScale: details.scale,
|
|
|
|
|
|
minScale: widget.minScale,
|
|
|
|
|
|
maxScale: widget.maxScale,
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
);
|
2026-06-23 01:45:54 +08:00
|
|
|
|
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);
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
if (focalDrop) {
|
|
|
|
|
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
|
|
|
|
|
record(1.0, false, true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
final Offset translationChange =
|
|
|
|
|
|
focalPointScene - _referenceFocalPoint!;
|
|
|
|
|
|
_transformer.value =
|
|
|
|
|
|
_matrixTranslate(_transformer.value, translationChange);
|
|
|
|
|
|
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
|
feat(diag): full input logging + read barrel from pointerFlags; focal-jump reject
Buttons (likely fix + ground truth): device diag showed ptr=12577 pen=10056 —
WM_POINTER reaches the observer and GetPointerPenInfo succeeds, so the buttons
were just read from the wrong field. Native now resolves the barrel from BOTH
penFlags(PEN_FLAG_BARREL) AND pointerInfo.pointerFlags(POINTER_FLAG_SECONDBUTTON)
— many pens use the latter. It also emits the full raw set (pointerFlags,
penFlags, penMask, ButtonChangeType, tilt) plus OR-accumulated flags so a single
session reveals exactly which field each button sets.
Comprehensive logging (per user request "用好用的log库 / 我手动开启日志再记录"):
new DiagnosticLogger emits through dart:developer log(name 'badnote.input') —
capturable via `flutter run` / DevTools / `flutter logs` — AND mirrors to a file
(path shown in the overlay) for the packaged GUI build that has no console.
Manually enabled by the toolbar diagnostic toggle; off by default. PEN lines log
on raw-field change; ZOOM lines log every scale frame + rebaselines.
Zoom: scale-only glitch rejection didn't stop the jumping, so add focal/position
glitch rejection — drop a 2-finger frame whose focal jumps >250px (a touch
misread). The full per-frame trace (raw scale, pointerCount, applied change,
focal jump, drops) is now logged so the residual cause is unambiguous.
InputDiagnostics singleton accumulates the stats; the overlay shows summary +
last trace lines + log path + reset. Removed the ad-hoc inline zoom min/max.
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:10:17 +08:00
|
|
|
|
record(1.0, false, false);
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onScaleEnd(ScaleEndDetails details) {
|
|
|
|
|
|
_scaleStart = null;
|
|
|
|
|
|
_referenceFocalPoint = null;
|
fix(pen): re-baseline zoom on pointer-count change; observe pen on child HWND
Zoom jumping (device: min 0.5 / max 2.47 while zooming near 1): the per-frame
scale clamp limited single-frame spikes but not multi-frame runs. Root cause is
pointer-count transitions — on Windows touch the two fingers land/lift at
different times and digitizers drop/re-acquire touches, and stock InteractiveViewer
keeps _scaleStart/_referenceFocalPoint from the OLD finger set, so the next frame
jumps. PenInteractiveViewer now re-baselines (and skips the transitional frame)
whenever details.pointerCount changes. The per-frame clamp stays as a secondary
guard.
Buttons (device evidence: btn=1 for tip-down, side-button, AND inverted; kind
never becomes invertedStylus): Flutter does NOT surface the barrel/eraser/inverted
state at all — unreachable from Dart. The only path is the native badnote/pen
plugin, which was SILENT because WM_POINTER is delivered to the Flutter CHILD
view window, not the top-level FlutterWindow where ObservePenMessage was hooked.
Fix: subclass the child HWND (SetWindowSubclass + comctl32) and observe its
WM_POINTER messages, passing every message through unchanged via DefSubclassProc
(observation-only, input behavior preserved). This is what should finally feed
GetPointerPenInfo penFlags + tilt to the channel — to be confirmed on-device with
the diagnostic (btn / kind / tilt readout).
Dart: analyze clean, 66/66 tests, linux build green. Native compiles on Windows CI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:14:50 +08:00
|
|
|
|
_lastPointerCount = 0;
|
feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of
Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite
boundary, no rotation — that machinery dropped as a no-op here). Two deliberate
changes, grounded in the Rnote/Saber research:
1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via
`supportedDevices`. The pen never reaches it, so a stylus stroke can no longer
be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by
stock IV's panEnabled updating a frame after the stroke began). Drawing is
owned solely by the canvas Listener; no arena fight, no panEnabled lag. The
prior _lastStylus hover hack is removed (superseded).
2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal
jitter and guards the pan branch, but a single-frame multi-touch glitch could
still spike details.scale, popping the zoom bigger/smaller and snapping back
(the reported pinch flicker). Clamping swallows the spike; a real (gradual)
pinch is unaffected since scale tracks absolutely from gesture start. Cap is
far above any real pinch (~1.1-1.2x/frame), so no felt lag.
Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is
Flutter's proven logic verbatim.
Also add an on-device input diagnostic (bug-report toggle): the existing pen
readout already prints kind/pressure/buttons; now it also shows live
zoom=now/min/max so the next device test captures (a) whether the side/eraser
button arrives as buttons/invertedStylus, and (b) the value any residual pinch
flash jumps to. 66/66 tests, analyze clean, linux build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:56 +08:00
|
|
|
|
_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);
|
|
|
|
|
|
}
|