fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag
All checks were successful
CI / Windows build (push) Successful in 17m58s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 22:02:17 +08:00
parent 45d89b7790
commit ae9e070b46
5 changed files with 147 additions and 89 deletions

View File

@@ -130,8 +130,10 @@ class _PenCanvasState extends State<PenCanvas> {
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<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(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<PenCanvas> {
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<PenCanvas> {
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<PenCanvas> {
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<PenCanvas> {
}
_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<PenCanvas> {
void _cancelStroke() {
_drawPointer = null;
_livePoints.clear();
_eraserCursor.value = null;
setState(() => _liveStroke = null);
}
@@ -400,7 +397,8 @@ class _PenCanvasState extends State<PenCanvas> {
'/${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<PenCanvas> {
_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<PenCanvas> {
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<PenCanvas> {
),
// 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<PenCanvas> {
radius: _eraserRadius,
aspect: _pageAspect,
pageSize: widget.pageSize,
thinning: widget.thinning,
),
),
),