From ae9e070b464e69f77e58821b049d9752bebfa7b8 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Mon, 22 Jun 2026 22:02:17 +0800 Subject: [PATCH] fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eraser (regression from the preview I added): - LAG: the preview did setState on every hover/erase-move (rebuilding the whole canvas) and recomputed perfect_freehand getStroke per overlapped stroke per frame. Now the cursor is a ValueNotifier driving the preview layer's repaint directly (no canvas rebuild), and the highlight is a plain polyline of the point-runs inside the radius (no getStroke). - STUCK RED ("一直红着"): the cursor was never cleared. Preview is now active-erase-only and cleared on pen up/cancel. - "选中了的笔画也不见得能删掉": radius was strokeWidth*2 (tiny) so a pass removed ~2 points and the stroke survived. Now a decisive fixed 0.02 (page-width fraction). The highlight traces exactly the point-run that splitStrokeByCircle removes, so what turns red is what gets deleted. Zoom: replace the per-frame scale CLAMP with glitch REJECTION — drop a frame demanding an implausible per-frame scale jump (>1.4x or <0.71x; a real pinch is ≲1.15x/frame). A dropped frame catches up the next frame (absolute tracking), so no lag, but the Windows multi-touch spike never shows. Pairs with the existing pointer-count re-baseline. Native diagnostic: ObservePenMessage now counts WM_POINTER* / PT_PEN / legacy mouse messages it sees and emits them on the channel; PenInputService exposes `debugSummary` and the overlay shows `native ptr=… pen=… mouse=… msg=0x…`. This will tell us on-device whether WM_POINTER ever reaches the observer (→ buttons recoverable) or Flutter is on a non-pointer path (→ not). Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/ink_painters.dart | 61 ++++++++----- lib/editor/canvas/pen_canvas.dart | 49 +++++------ lib/editor/canvas/pen_interactive_viewer.dart | 24 +++--- lib/editor/input/pen_input_service.dart | 17 ++++ windows/runner/pen_channel.cpp | 85 ++++++++++++------- 5 files changed, 147 insertions(+), 89 deletions(-) diff --git a/lib/editor/canvas/ink_painters.dart b/lib/editor/canvas/ink_painters.dart index 247b4be..fc6e54a 100644 --- a/lib/editor/canvas/ink_painters.dart +++ b/lib/editor/canvas/ink_painters.dart @@ -4,10 +4,10 @@ // coordinates; both painters receive the on-screen page [Size] and scale // points into pixels at paint time. perfect_freehand produces the outline. +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:perfect_freehand/perfect_freehand.dart' as pf; -import '../engine/stroke_eraser.dart' show strokeHit; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import 'pen_stroke.dart'; @@ -116,13 +116,13 @@ class EraserPreviewPainter extends CustomPainter { required this.radius, required this.aspect, required this.pageSize, - this.thinning = kDefaultPenThinning, - }); + }) : super(repaint: cursor); final List strokes; - /// Eraser center in normalized page coords, or null when no preview. - final PenPoint? cursor; + /// Eraser center in normalized page coords (null = no preview). A listenable + /// so the painter repaints on cursor moves WITHOUT rebuilding the canvas. + final ValueListenable cursor; /// Eraser radius as a fraction of page width (matches the live erase test). final double radius; @@ -131,25 +131,44 @@ class EraserPreviewPainter extends CustomPainter { final double aspect; final Size pageSize; - final double thinning; @override void paint(Canvas canvas, Size size) { - final c = cursor; + final c = cursor.value; if (c == null) return; - // Faint outline on each stroke the eraser currently overlaps. + // CHEAP, ACCURATE highlight: trace ONLY the point-runs inside the eraser + // radius — i.e. exactly what splitStrokeByCircle will remove — as a plain + // polyline (no perfect_freehand getStroke; that was the eraser lag source). + // So what turns red is exactly what gets deleted. + final r2 = radius * radius; final highlight = Paint() - ..color = const Color(0xFFFF5252).withValues(alpha: 0.55) + ..color = const Color(0xFFFF5252).withValues(alpha: 0.85) ..style = PaintingStyle.stroke - ..strokeWidth = 1.5 + ..strokeWidth = 3.0 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round ..isAntiAlias = true; for (final stroke in strokes) { - if (!strokeHit(stroke, c.x, c.y, radius, aspect: aspect)) continue; - final path = - buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning); - if (path.getBounds().isEmpty) continue; - canvas.drawPath(path, highlight); + Path? run; + void flush() { + if (run != null) { + canvas.drawPath(run!, highlight); + run = null; + } + } + + for (final pt in stroke.points) { + final dx = pt.x - c.x; + final dy = (pt.y - c.y) * aspect; + if (dx * dx + dy * dy < r2) { + final o = Offset(pt.x * pageSize.width, pt.y * pageSize.height); + (run ??= Path()..moveTo(o.dx, o.dy)).lineTo(o.dx, o.dy); + } else { + flush(); + } + } + flush(); } // The eraser circle itself (radius is a page-width fraction → px = r * w). @@ -159,7 +178,7 @@ class EraserPreviewPainter extends CustomPainter { center, rPx, Paint() - ..color = const Color(0xFF9E9E9E).withValues(alpha: 0.5) + ..color = const Color(0xFF757575).withValues(alpha: 0.7) ..style = PaintingStyle.stroke ..strokeWidth = 1.0 ..isAntiAlias = true, @@ -167,22 +186,18 @@ class EraserPreviewPainter extends CustomPainter { canvas.drawCircle( center, rPx, - Paint() - ..color = const Color(0x14000000) - ..style = PaintingStyle.fill, + Paint()..color = const Color(0x14000000), ); } @override bool shouldRepaint(EraserPreviewPainter old) => - old.cursor?.x != cursor?.x || - old.cursor?.y != cursor?.y || + !identical(old.cursor, cursor) || old.radius != radius || old.aspect != aspect || !identical(old.strokes, strokes) || old.strokes.length != strokes.length || - old.pageSize != pageSize || - old.thinning != thinning; + old.pageSize != pageSize; } /// Paints just the in-progress stroke (the live layer), kept behind its own diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index d4a9ab5..65db3b3 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -130,8 +130,10 @@ class _PenCanvasState extends State { bool _eraserActive = false; /// Eraser preview cursor (normalized page coords), or null when not in eraser - /// mode / the pen is not near the page. Drives [EraserPreviewPainter]. - PenPoint? _eraserCursor; + /// mode / the pen is not near the page. A ValueNotifier so the preview layer + /// repaints on cursor moves WITHOUT rebuilding the whole canvas every frame + /// (the old per-move setState was the eraser-lag source). + final ValueNotifier _eraserCursor = ValueNotifier(null); /// True when the eraser would act (eraser tool selected, or a barrel/inverted /// eraser signal is live). @@ -139,23 +141,16 @@ class _PenCanvasState extends State { widget.tool == CanvasTool.eraser || _eraserActive; /// Eraser radius as a fraction of page width (shared by the live erase and the - /// preview overlay so they always agree). - double get _eraserRadius => widget.strokeWidth * 2; + /// preview overlay so they always agree). A decisive fixed size — the old + /// strokeWidth*2 was so small that a pass removed only a couple of points and + /// the stroke visibly survived ("选中了的笔画也不见得能删掉"). + static const double _eraserRadius = 0.02; /// Page aspect (height / width) so the eraser circle stays round on screen. double get _pageAspect => widget.pageSize.width <= 0 ? 1.0 : widget.pageSize.height / widget.pageSize.width; - /// Update (or clear) the eraser-preview cursor from a global pointer position. - void _updateEraserCursor(Offset globalPosition) { - if (_isEraserMode) { - setState(() => _eraserCursor = _toNormalized(globalPosition, null)); - } else if (_eraserCursor != null) { - setState(() => _eraserCursor = null); - } - } - // The explicit user toggle wins: if finger-drawing is ON, a single finger // draws even after a stylus has been seen. (Palm rejection when the toggle is // OFF is automatic — fingers simply never draw — and a 2nd pointer always @@ -295,12 +290,12 @@ class _PenCanvasState extends State { if (p != null) _livePoints.add(p); if (_eraserActive || widget.tool == CanvasTool.eraser) { + _eraserCursor.value = p; _eraseAt(p); - // Keep the stroke pointer reserved so moves keep erasing, but don't paint. - setState(() { - _liveStroke = null; - _eraserCursor = p; - }); + // No setState here: the preview repaints via the notifier, and any erased + // stroke repaints via the editor's onEraseStroke setState. (_liveStroke is + // already null in eraser mode.) + if (_liveStroke != null) setState(() => _liveStroke = null); return; } _updateLiveStroke(); @@ -312,8 +307,8 @@ class _PenCanvasState extends State { if (p == null) return; if (_eraserActive || widget.tool == CanvasTool.eraser) { + _eraserCursor.value = p; _eraseAt(p); - setState(() => _eraserCursor = p); return; } _livePoints.add(p); @@ -335,6 +330,7 @@ class _PenCanvasState extends State { } _drawPointer = null; _livePoints.clear(); + _eraserCursor.value = null; // hide the preview when the pen lifts setState(() => _liveStroke = null); } @@ -342,6 +338,7 @@ class _PenCanvasState extends State { void _cancelStroke() { _drawPointer = null; _livePoints.clear(); + _eraserCursor.value = null; setState(() => _liveStroke = null); } @@ -400,7 +397,8 @@ class _PenCanvasState extends State { '/${event.pressureMax.toStringAsFixed(0)} ' 'norm=${norm?.toStringAsFixed(3) ?? "null"} ' 'peak=${_peakNorm.toStringAsFixed(3)} ' - 'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}'); + 'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}' + '\n${PenInputService.instance.debugSummary}'); } void _onPointerHover(PointerHoverEvent event) { @@ -411,8 +409,6 @@ class _PenCanvasState extends State { _dispatchHwButtonActions(); // Detect eraser (barrel button / inverted) while hovering. _eraserActive = _isEraserSignal(event); - // Live eraser-preview cursor follows the hovering pen. - _updateEraserCursor(event.position); } } @@ -467,6 +463,12 @@ class _PenCanvasState extends State { if (wasDrawer) _cancelStroke(); } + @override + void dispose() { + _eraserCursor.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { // The PEN never reaches PenInteractiveViewer's recognizer (it excludes @@ -516,7 +518,7 @@ class _PenCanvasState extends State { ), // Eraser preview: faint outline on strokes about to be deleted + // the eraser circle. Mounted only in eraser mode with a cursor. - if (_isEraserMode && _eraserCursor != null) + if (_isEraserMode) Positioned.fill( child: RepaintBoundary( child: CustomPaint( @@ -526,7 +528,6 @@ class _PenCanvasState extends State { radius: _eraserRadius, aspect: _pageAspect, pageSize: widget.pageSize, - thinning: widget.thinning, ), ), ), diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart index 66940d9..029d4b2 100644 --- a/lib/editor/canvas/pen_interactive_viewer.dart +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -40,9 +40,11 @@ const Set _kPanZoomDevices = { PointerDeviceKind.unknown, }; -/// Per-frame multiplicative scale-change clamp (flicker guard). -const double _kMaxScaleChangePerFrame = 1.35; -const double _kMinScaleChangePerFrame = 1 / _kMaxScaleChangePerFrame; +/// 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; const double _kDrag = 0.0000135; @@ -177,14 +179,14 @@ class _PenInteractiveViewerState extends State case _GestureType.scale: assert(_scaleStart != null); final double desiredScale = _scaleStart! * details.scale; - // Flicker guard: clamp the per-frame change so a single-frame touch - // jitter can't pop the zoom and snap back. Absolute tracking means a - // real pinch just resumes next frame. - final double scaleChange = clampDouble( - desiredScale / scale, - _kMinScaleChangePerFrame, - _kMaxScaleChangePerFrame, - ); + final double scaleChange = desiredScale / scale; + // Glitch rejection: drop a frame that demands an implausible per-frame + // scale jump (a Windows multi-touch position glitch). The next good + // frame resumes from the true finger positions, so the spike never + // shows — unlike clamping, which still applied a visible partial jump. + if (scaleChange > _kScaleGlitchHi || scaleChange < _kScaleGlitchLo) { + return; + } _transformer.value = _matrixScale(_transformer.value, scaleChange); // Keep the focal point anchored under the fingers across the scale. diff --git a/lib/editor/input/pen_input_service.dart b/lib/editor/input/pen_input_service.dart index c2d5218..a10c9f3 100644 --- a/lib/editor/input/pen_input_service.dart +++ b/lib/editor/input/pen_input_service.dart @@ -94,6 +94,19 @@ class PenInputService { bool get isActive => _active; bool _active = false; + // Native-side diagnostics (see windows/runner/pen_channel.cpp): how many + // WM_POINTER / PT_PEN / legacy-mouse messages the observer has seen. Lets the + // on-device overlay tell us WHICH layer is failing for buttons/tilt. + int _diagPtr = 0; + int _diagPen = 0; + int _diagMouse = 0; + int _diagMsg = 0; + + /// One-line native readout for the diagnostic overlay. + String get debugSummary => _active + ? 'native ptr=$_diagPtr pen=$_diagPen mouse=$_diagMouse msg=0x${_diagMsg.toRadixString(16)}' + : 'native: channel silent (no events)'; + /// Begins listening to the native channel. Idempotent; safe on any platform /// (no-ops where the channel has no handler). void start() { @@ -123,6 +136,10 @@ class PenInputService { tiltX: (event['tiltX'] as num?)?.toDouble() ?? 0.0, tiltY: (event['tiltY'] as num?)?.toDouble() ?? 0.0, ); + _diagPtr = (event['diagPtr'] as num?)?.toInt() ?? _diagPtr; + _diagPen = (event['diagPen'] as num?)?.toInt() ?? _diagPen; + _diagMouse = (event['diagMouse'] as num?)?.toInt() ?? _diagMouse; + _diagMsg = (event['diagMsg'] as num?)?.toInt() ?? _diagMsg; _active = true; } diff --git a/windows/runner/pen_channel.cpp b/windows/runner/pen_channel.cpp index f6aa77f..c3d6282 100644 --- a/windows/runner/pen_channel.cpp +++ b/windows/runner/pen_channel.cpp @@ -14,6 +14,15 @@ std::unique_ptr> g_pen_sink; std::unique_ptr> g_pen_channel; +// Diagnostic counters so the Dart side can see WHAT the observer receives: +// - g_ptr_msgs: WM_POINTER* messages seen (is WM_POINTER reaching us at all?) +// - g_pen_msgs: of those, PT_PEN with a successful GetPointerPenInfo +// - g_mouse_msgs: legacy mouse/touch input messages (Flutter on the old path?) +int g_ptr_msgs = 0; +int g_pen_msgs = 0; +int g_mouse_msgs = 0; +int g_last_msg = 0; + } // namespace void RegisterPenChannel(flutter::FlutterEngine* engine) { @@ -43,47 +52,61 @@ void RegisterPenChannel(flutter::FlutterEngine* engine) { } void ObservePenMessage(UINT message, WPARAM wparam, LPARAM lparam) { - if (message != WM_POINTERENTER && message != WM_POINTERDOWN && - message != WM_POINTERUPDATE && message != WM_POINTERUP) { - return; - } - if (!g_pen_sink) { return; } - UINT32 pointerId = GET_POINTERID_WPARAM(wparam); + const bool is_pointer = + message == WM_POINTERENTER || message == WM_POINTERDOWN || + message == WM_POINTERUPDATE || message == WM_POINTERUP; + // Legacy input path: if Flutter is feeding us mouse/touch instead of pointer + // messages, these tell us so (so we know WM_POINTER never arrives here). + const bool is_legacy_input = + message == WM_LBUTTONDOWN || message == WM_LBUTTONUP || + message == WM_MOUSEMOVE || message == WM_TOUCH; - POINTER_INPUT_TYPE type = PT_POINTER; - if (!GetPointerType(pointerId, &type) || type != PT_PEN) { + if (!is_pointer && !is_legacy_input) { return; } - - POINTER_PEN_INFO ppi{}; - if (!GetPointerPenInfo(pointerId, &ppi)) { - return; + g_last_msg = static_cast(message); + if (is_legacy_input) { + ++g_mouse_msgs; } int flags = 0; - if (ppi.penFlags & PEN_FLAG_BARREL) flags |= 1; - if (ppi.penFlags & PEN_FLAG_INVERTED) flags |= 2; - if (ppi.penFlags & PEN_FLAG_ERASER) flags |= 4; + double tilt_x = 0.0; + double tilt_y = 0.0; - flutter::EncodableMap payload{ - {flutter::EncodableValue("flags"), flutter::EncodableValue(flags)}, - {flutter::EncodableValue("tiltX"), flutter::EncodableValue(static_cast(ppi.tiltX))}, - {flutter::EncodableValue("tiltY"), flutter::EncodableValue(static_cast(ppi.tiltY))}, - }; - - g_pen_sink->Success(flutter::EncodableValue(payload)); - - // On pointer up, send a cleared flags event to signal lift-off. - if (message == WM_POINTERUP) { - flutter::EncodableMap clear{ - {flutter::EncodableValue("flags"), flutter::EncodableValue(0)}, - {flutter::EncodableValue("tiltX"), flutter::EncodableValue(0.0)}, - {flutter::EncodableValue("tiltY"), flutter::EncodableValue(0.0)}, - }; - g_pen_sink->Success(flutter::EncodableValue(clear)); + if (is_pointer) { + ++g_ptr_msgs; + UINT32 pointerId = GET_POINTERID_WPARAM(wparam); + POINTER_INPUT_TYPE type = PT_POINTER; + if (GetPointerType(pointerId, &type) && type == PT_PEN) { + POINTER_PEN_INFO ppi{}; + if (GetPointerPenInfo(pointerId, &ppi)) { + ++g_pen_msgs; + if (ppi.penFlags & PEN_FLAG_BARREL) flags |= 1; + if (ppi.penFlags & PEN_FLAG_INVERTED) flags |= 2; + if (ppi.penFlags & PEN_FLAG_ERASER) flags |= 4; + tilt_x = static_cast(ppi.tiltX); + tilt_y = static_cast(ppi.tiltY); + } + } + if (message == WM_POINTERUP) { + flags = 0; // lift-off clears held flags + } } + + // Always emit the diagnostic counters so the Dart overlay can show whether + // WM_POINTER / PT_PEN ever reach this observer. + flutter::EncodableMap payload{ + {flutter::EncodableValue("flags"), flutter::EncodableValue(flags)}, + {flutter::EncodableValue("tiltX"), flutter::EncodableValue(tilt_x)}, + {flutter::EncodableValue("tiltY"), flutter::EncodableValue(tilt_y)}, + {flutter::EncodableValue("diagPtr"), flutter::EncodableValue(g_ptr_msgs)}, + {flutter::EncodableValue("diagPen"), flutter::EncodableValue(g_pen_msgs)}, + {flutter::EncodableValue("diagMouse"), flutter::EncodableValue(g_mouse_msgs)}, + {flutter::EncodableValue("diagMsg"), flutter::EncodableValue(g_last_msg)}, + }; + g_pen_sink->Success(flutter::EncodableValue(payload)); }