Files
BadNote/lib/editor/canvas/pen_canvas.dart

828 lines
32 KiB
Dart
Raw Normal View History

// lib/editor/canvas/pen_canvas.dart
//
// Pen-first canvas: ONE shared transform (an InteractiveViewer driven by a
// TransformationController we own) zooms/pans BOTH the PDF page bitmap and the
// ink layer together. A Listener wrapped around the InteractiveViewer reads raw
// pointer kind + pressure and tracks the active pointer COUNT to arbitrate
// draw vs pan/zoom — we own the gesture pipeline, pdfrx never sees gestures.
//
// Gesture arbitration (reimplemented clean-room from Saber's documented model):
// - A draw gesture is exactly ONE active pointer that is a stylus / inverted
// stylus, OR (when the user's finger-drawing toggle is on) a single finger.
// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw. If a 2nd
// pointer lands while a stroke is in progress, that stroke is discarded
// (accidental palm/finger).
// - Palm rejection: once any stylus event is seen in a session, finger-drawing
// is forced OFF so a resting palm/finger pans instead of marking.
// - While a stroke is active, the InteractiveViewer's pan is disabled so it
// can't fight the stroke; pinch-zoom still works because a 2nd pointer
// cancels the stroke first, re-enabling pan/zoom.
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
2026-06-23 02:59:30 +08:00
import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
import '../input/pen_input_service.dart';
import '../engine/shape_geometry.dart';
2026-06-23 02:59:30 +08:00
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'editor_tool.dart';
import 'ink_painters.dart' show EraserPreviewPainter, SelectionOverlayPainter;
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
import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart';
/// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
/// triad; [select] and [shape] are the core-writing-batch additions. This mirrors
/// the shared [EditorToolKind] (the PDF editor uses that enum directly); the
/// PenCanvas keeps its own enum because it predates the shared model and is wired
/// through many call sites — see [editorToolToCanvas].
enum CanvasTool { pen, highlighter, eraser, select, shape }
/// Map the shared [EditorToolKind] to the PenCanvas's [CanvasTool] so the note/
/// slide editors can drive PenCanvas from the shared active-tool state.
CanvasTool editorToolToCanvas(EditorToolKind kind) => switch (kind) {
EditorToolKind.brush => CanvasTool.pen,
EditorToolKind.highlighter => CanvasTool.highlighter,
EditorToolKind.eraser => CanvasTool.eraser,
EditorToolKind.select => CanvasTool.select,
EditorToolKind.shape => CanvasTool.shape,
};
class PenCanvas extends StatefulWidget {
const PenCanvas({
super.key,
required this.pageWidget,
required this.pageSize,
required this.strokes,
required this.transformationController,
required this.tool,
this.brush = BrushKind.fountainPen,
this.shapeKind = ShapeKind.line,
required this.color,
required this.strokeWidth,
required this.onStrokeComplete,
required this.onEraseStroke,
this.selectedStrokeIndex,
this.onSelectStroke,
this.onMoveStroke,
this.allowFingerDrawing = false,
this.minScale = 0.5,
this.maxScale = 8.0,
this.onPenDebug,
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
this.thinning = kDefaultPenThinning,
this.pressureGamma = kNaturalPressureGamma,
this.pressureFloor = kNaturalPressureFloor,
this.eraserRadius = kDefaultEraserRadius,
this.eraserWholeStroke = false,
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
this.sideButtonAction = PenButtonAction.eraser,
this.eraserEndAction = PenButtonAction.eraser,
this.onPenButtonAction,
});
/// Debug hook: called with a readout of the latest pen event
/// (kind / pressure / pressureMin / pressureMax) so we can see what Windows
/// actually delivers. Null in release UI.
final void Function(String readout)? onPenDebug;
/// The rendered PDF page bitmap, already sized to [pageSize].
final Widget pageWidget;
/// On-screen size (at scale 1.0) of the page rectangle in logical pixels.
/// Ink normalized coords map onto this rectangle.
final Size pageSize;
/// Committed strokes for the CURRENT page (normalized coords).
final List<PenStroke> strokes;
/// Shared transform driving both page and ink.
final TransformationController transformationController;
final CanvasTool tool;
/// The brush selected for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always renders with [BrushKind.highlighter] regardless of
/// this value; the eraser draws nothing. Drives both the capture-time pressure
/// pre-warp ([BrushProfile.pressureGamma]) and the render geometry.
final BrushKind brush;
/// The shape to draw when [tool] is [CanvasTool.shape]. Generated as a
/// PenStroke via [generateShapePoints] (no new model).
final ShapeKind shapeKind;
final Color color;
/// Pen width as a fraction of page width (so it zooms with the page).
final double strokeWidth;
/// Called with a finished stroke (normalized coords) to commit it.
final void Function(PenStroke stroke) onStrokeComplete;
/// Called to replace committed stroke [strokeIndex] with its surviving pieces
/// after a partial (segment) erase. An empty [replacements] list removes the
/// stroke entirely (whole-stroke erase).
final void Function(int strokeIndex, List<PenStroke> replacements)
onEraseStroke;
/// Index of the currently selected committed stroke (SELECT tool), or null.
/// Drives the selection bounding-box overlay.
final int? selectedStrokeIndex;
/// Called when the SELECT tool taps a committed stroke (its index), or null
/// when the tap hits empty space (clears the selection).
final ValueChanged<int?>? onSelectStroke;
/// Called when the SELECT tool drags the selected stroke: ([strokeIndex],
/// [dx],[dy]) is the normalized translation to apply, and [isDragStart] is true
/// on the FIRST delta of a drag so the parent records ONE undo snapshot per
/// drag (not per pixel). The parent translates + persists (see
/// `translateStroke`).
final void Function(int strokeIndex, double dx, double dy, bool isDragStart)?
onMoveStroke;
/// User toggle: allow a single finger to draw. Forced off once a stylus is
/// seen (palm rejection).
final bool allowFingerDrawing;
final double minScale;
final double maxScale;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
final double thinning;
/// Pressure-response exponent applied to raw stylus pressure BEFORE it reaches
/// perfect_freehand. <1 boosts light touches (responsive, rnote-like); 1 is
/// raw linear (the old "pressure-finger" feel). From `PenConfig.pressureGamma`.
///
/// TODO(brush-pressure-knob): superseded by the per-brush
/// [BrushProfile.pressureGamma] (fountain p², pencil √p) which now drives the
/// capture-time warp. This config knob is retained for the API + future
/// reconciliation (e.g. a user multiplier on top of the brush curve) but is no
/// longer read by [_normalizedPressure].
final double pressureGamma;
/// Minimum shaped pressure, so a light stroke still has body instead of
/// scratchy near-zero width. From `PenConfig.pressureFloor`.
final double pressureFloor;
/// Eraser radius as a fraction of page width (live hit area + cursor size).
/// From `PenConfig.eraserRadius`.
final double eraserRadius;
/// When true the eraser removes a whole stroke on contact (OneNote-style);
/// when false it does a partial / segment erase. From
/// `PenConfig.eraserWholeStroke`.
final bool eraserWholeStroke;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
/// Configured action for the pen's side barrel button (W3 — resolved against
/// the native pen plugin's flags on Windows).
final PenButtonAction sideButtonAction;
/// Configured action for the pen's eraser/inverted end (W3).
final PenButtonAction eraserEndAction;
/// Fired (edge-triggered) when a hardware pen button mapped to a non-eraser
/// action (undo / toggleTool) is pressed.
final void Function(PenButtonAction action)? onPenButtonAction;
@override
State<PenCanvas> createState() => _PenCanvasState();
}
class _PenCanvasState extends State<PenCanvas> {
/// Active (down) pointers by id → their device kind. Size == pointer count.
final Map<int, PointerDeviceKind> _activePointers = {};
/// The pointer id currently driving a stroke, or null.
int? _drawPointer;
/// In-progress stroke points (normalized).
final List<PenPoint> _livePoints = [];
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke;
/// SHAPE tool: the normalized start point of the in-progress shape, or null.
PenPoint? _shapeStart;
/// SELECT tool: the last normalized drag position, used to compute the
/// incremental translation reported to [PenCanvas.onMoveStroke].
PenPoint? _selectLast;
/// SELECT tool: true once a drag of the selected stroke has begun (so the move
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
bool _selectDragging = false;
/// True when the active stylus reports the eraser signal (barrel button or
/// inverted stylus), detected on hover/down.
bool _eraserActive = false;
/// Eraser preview cursor (normalized page coords), or null when not in eraser
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
/// 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);
2026-06-23 02:59:30 +08:00
/// Committed ink mirrored as the canonical [EditorStroke] model, driving the
/// revision-gated [render.StaticInkPainter] + [InkPictureCache] (P0 step 3).
/// The cache replays a recorded ui.Picture for the committed layer, so pinch /
/// pan / live-stroke frames never re-rasterize the committed ink — the
/// P0.5 perf prerequisite. Kept in sync with [PenCanvas.strokes] (which the
/// parent replaces with a fresh list identity on every commit/erase).
final StrokeStore _store = StrokeStore();
final InkPictureCache _inkCache = InkPictureCache();
List<PenStroke>? _syncedStrokesRef;
static const String _inkHostId = 'pen-canvas';
/// Re-mirror [PenCanvas.strokes] into [_store] when the parent hands us a new
/// list (identity change ⇒ a commit/erase happened). Bumping the store
/// revision invalidates the cached Picture so the committed layer repaints.
void _syncStore() {
if (identical(_syncedStrokesRef, widget.strokes)) return;
_syncedStrokesRef = widget.strokes;
_store.replaceAll(
widget.strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(),
);
}
/// True when the eraser would act (eraser tool selected, or a barrel/inverted
/// eraser signal is live).
bool get _isEraserMode =>
widget.tool == CanvasTool.eraser || _eraserActive;
/// 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;
/// Normalized bounding box of the currently selected stroke (SELECT tool), or
/// null when nothing valid is selected.
Rect? get _selectionBounds {
final idx = widget.selectedStrokeIndex;
if (idx == null || idx < 0 || idx >= widget.strokes.length) return null;
final b = penStrokeBounds(widget.strokes[idx]);
if (b == null) return null;
return Rect.fromLTRB(b.left, b.top, b.right, b.bottom);
}
// 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
// cancels an in-progress stroke regardless.)
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
/// The brush in effect for the current tool: highlighter tool ⇒ highlighter
/// brush, otherwise the selected pen brush. (Eraser draws nothing, so its
/// brush is irrelevant.)
BrushKind get _currentBrush => widget.tool == CanvasTool.highlighter
? BrushKind.highlighter
: widget.brush;
/// The brush profile in effect, for the capture-time pressure pre-warp.
BrushProfile get _currentBrushProfile => brushProfileFor(_currentBrush);
/// Normalize stylus pressure to [0,1], or null when the device reports no
/// usable pressure range (then perfect_freehand simulates pressure).
///
/// The raw normalized force is then shaped by the pressure-response curve
/// (floor + gamma) so the stored pressure already carries the rnote-like feel
/// — and because the shaping happens at capture, the live stroke and the PDF
/// export replay identical pressures (no divergence).
double? _normalizedPressure(PointerEvent event) {
if (!_isStylus(event.kind)) return null;
final double? raw = _rawNormalizedPressure(event);
if (raw == null) return null;
// Pre-warp pressure with the BRUSH's gamma (rnote PressureCurve: fountain
// = Pow2/p², pencil = Sqrt/√p, ballpoint/highlighter = Linear), reusing the
// existing PressureCurve. Baking the warp in at capture means the live
// stroke and the export replay identical pressures (no divergence). The
// brush gamma supersedes the legacy per-config `pressureGamma` knob — see
// TODO(brush-pressure-knob) on `widget.pressureGamma`.
return PressureCurve(
floor: widget.pressureFloor,
gamma: _currentBrushProfile.pressureGamma,
).apply(raw);
}
/// Raw [0,1] stylus force before response shaping (see [_normalizedPressure]).
double? _rawNormalizedPressure(PointerEvent event) {
final range = event.pressureMax - event.pressureMin;
if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
}
// No advertised range (some Windows pen stacks): use the raw normalized
// pressure directly if it's a usable non-degenerate value, so we still get
// real force instead of falling back to velocity simulation.
if (event.pressure > 0.0 && event.pressure < 1.0) {
return event.pressure;
}
return null;
}
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
/// The eraser signal. Two sources, ORed:
/// 1. Flutter-native: secondary button held or an inverted stylus (works on
/// desktop / platforms that surface these).
/// 2. Windows native pen plugin: barrel / inverted / eraser flags that
/// Flutter 3.44 drops, mapped through the configured side-button /
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
/// erasing — correct for an eraser.
bool _isEraserSignal(PointerEvent event) {
// BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
// == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
// down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
// (0x02) is false — the side button registered only on hover, never while
// drawing. `& kSecondaryButton != 0` catches both.
if ((event.buttons & kSecondaryButton) != 0 ||
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
event.kind == PointerDeviceKind.invertedStylus) {
return true;
}
final hw = PenInputService.instance;
if (hw.isActive) {
final s = hw.current;
if ((s.inverted || s.eraser) &&
widget.eraserEndAction == PenButtonAction.eraser) {
return true;
}
if (s.barrel && widget.sideButtonAction == PenButtonAction.eraser) {
return true;
}
}
return false;
}
/// Resolve the currently-active configured action from the native pen flags
/// (eraser-end takes precedence over the side button when both are set).
PenButtonAction _activeHwAction() {
final hw = PenInputService.instance;
if (!hw.isActive) return PenButtonAction.none;
final s = hw.current;
if (s.inverted || s.eraser) return widget.eraserEndAction;
if (s.barrel) return widget.sideButtonAction;
return PenButtonAction.none;
}
/// Last hardware action seen, for rising-edge detection of undo/toggleTool.
PenButtonAction _lastHwAction = PenButtonAction.none;
/// Edge-triggered dispatch of non-eraser button actions (undo / toggleTool).
/// Eraser is handled level-triggered by [_isEraserSignal]; pan suppresses
/// drawing via [_shouldDraw].
void _dispatchHwButtonActions() {
final action = _activeHwAction();
if (action == _lastHwAction) return;
_lastHwAction = action;
if (action == PenButtonAction.undo ||
action == PenButtonAction.toggleTool) {
widget.onPenButtonAction?.call(action);
}
}
/// True while a hardware button mapped to `pan` is held (suppresses drawing
/// so the InteractiveViewer pans instead).
bool get _hwPanActive => _activeHwAction() == PenButtonAction.pan;
/// Pen tilt magnitude (degrees) for a stylus event, or null when unavailable.
double? _tiltFor(PointerEvent event) {
if (!_isStylus(event.kind)) return null;
final hw = PenInputService.instance;
if (!hw.isActive) return null;
final t = hw.current.tiltMagnitude;
return t == 0 ? null : t;
}
/// Decide whether the gesture currently forming should DRAW. Delegates to the
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
/// the tests can never disagree on the rule.
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
// --- Coordinate mapping ---------------------------------------------------
/// Map a global pointer position into normalized page coords using the
/// shared transform (inverse) and this widget's geometry.
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
PenPoint? _toNormalized(Offset globalPosition, double? pressure,
{double? tilt}) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return null;
final local = box.globalToLocal(globalPosition);
// Undo the InteractiveViewer transform to get scene (untransformed) coords.
final scene = widget.transformationController.toScene(local);
final nx = scene.dx / widget.pageSize.width;
final ny = scene.dy / widget.pageSize.height;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
return PenPoint(nx, ny, pressure, tilt: tilt);
}
// --- Stroke lifecycle -----------------------------------------------------
void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer;
_livePoints.clear();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
if (_eraserActive || widget.tool == CanvasTool.eraser) {
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
_eraserCursor.value = p;
_eraseAt(p);
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
// 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;
}
// SELECT: tap hit-tests the committed strokes (topmost first) and reports
// the selection. A subsequent drag translates it (see _extendStroke).
if (widget.tool == CanvasTool.select) {
if (p != null) {
_selectLast = p;
widget.onSelectStroke?.call(_hitTestStroke(p));
}
return;
}
// SHAPE: record the start point; the preview shape is built on each move.
if (widget.tool == CanvasTool.shape) {
_shapeStart = p;
return;
}
if (p != null) _livePoints.add(p);
_updateLiveStroke();
}
void _extendStroke(PointerMoveEvent event) {
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
if (p == null) return;
if (_eraserActive || widget.tool == CanvasTool.eraser) {
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
_eraserCursor.value = p;
_eraseAt(p);
return;
}
// SELECT drag: translate the selected stroke by the incremental delta.
if (widget.tool == CanvasTool.select) {
final last = _selectLast;
final idx = widget.selectedStrokeIndex;
if (last != null && idx != null) {
final dx = p.x - last.x;
final dy = p.y - last.y;
if (dx != 0 || dy != 0) {
final isStart = !_selectDragging;
_selectDragging = true;
widget.onMoveStroke?.call(idx, dx, dy, isStart);
}
}
_selectLast = p;
return;
}
// SHAPE preview: regenerate the shape from start→current on every move.
if (widget.tool == CanvasTool.shape) {
_updateShapePreview(p);
return;
}
_livePoints.add(p);
_updateLiveStroke();
}
void _endStroke() {
if (_drawPointer == null) return;
final tool = widget.tool;
final wasEraser = _eraserActive || tool == CanvasTool.eraser;
if (tool == CanvasTool.shape) {
// Commit the generated shape stroke (if the drag spanned any distance).
final start = _shapeStart;
final end = _livePoints.isNotEmpty ? _livePoints.last : null;
if (start != null && end != null) {
final pts = generateShapePoints(widget.shapeKind, start, end);
widget.onStrokeComplete(PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: _currentBrush,
));
}
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
widget.onStrokeComplete(
PenStroke(
points: List.of(_livePoints),
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: _currentKind(),
brush: _currentBrush,
),
);
}
_drawPointer = null;
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
_eraserCursor.value = null; // hide the preview when the pen lifts
setState(() => _liveStroke = null);
}
/// Hit-test committed strokes (topmost first) at normalized [p]; returns the
/// index of the first stroke within the eraser radius, or null. Reuses
/// [strokeHit] so tap-select matches the eraser's proximity model.
int? _hitTestStroke(PenPoint p) {
final radius = widget.eraserRadius;
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) {
if (strokeHit(widget.strokes[i], p.x, p.y, radius, aspect: aspect)) {
return i;
}
}
return null;
}
/// Build the SHAPE preview stroke from the recorded start to the current [p].
void _updateShapePreview(PenPoint p) {
final start = _shapeStart;
if (start == null) return;
_livePoints
..clear()
..add(p); // remember the latest end point for commit
final pts = generateShapePoints(widget.shapeKind, start, p);
setState(() {
_liveStroke = PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: _currentBrush,
);
});
}
/// Discard the in-progress stroke without committing (palm/2nd-finger).
void _cancelStroke() {
_drawPointer = null;
_livePoints.clear();
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
_eraserCursor.value = null;
setState(() => _liveStroke = null);
}
void _updateLiveStroke() {
setState(() {
_liveStroke = PenStroke(
points: List.of(_livePoints),
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: _currentKind(),
brush: _currentBrush,
);
});
}
PenStrokeKind _currentKind() =>
widget.tool == CanvasTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen;
Color _currentColor() => widget.tool == CanvasTool.highlighter
? widget.color.withAlpha(0x80)
: widget.color;
/// Partial (segment) erase: find the first committed stroke the eraser circle
/// touches and replace it with its surviving pieces. The eraser radius is in
/// normalized page-width fractions; [aspect] corrects the y axis so the circle
/// stays round on screen (the page rect is not square).
void _eraseAt(PenPoint? p) {
if (p == null) return;
final radius = widget.eraserRadius; // normalized (page-width fraction)
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) {
final stroke = widget.strokes[i];
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
// Stroke-eraser mode: a hit removes the entire stroke (empty replacement).
// Point-eraser mode (default): cut out the touched span, keep the rest.
final pieces = widget.eraserWholeStroke
? const <PenStroke>[]
: splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect);
// Defensive no-op guard (strokeHit already passed, so a hit is expected).
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
widget.onEraseStroke(i, pieces);
return;
}
}
// --- Listener callbacks ---------------------------------------------------
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
/// Highest NORMALIZED pressure seen since the diagnostic was last reset —
/// makes "does pressure actually vary?" unambiguous on the readout.
double _peakNorm = 0;
void _emitPenDebug(PointerEvent event) {
final cb = widget.onPenDebug;
if (cb == null) return;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
final norm = _normalizedPressure(event);
if (norm != null && norm > _peakNorm) _peakNorm = norm;
cb('${event.kind.name} raw=${event.pressure.toStringAsFixed(1)}'
'/${event.pressureMax.toStringAsFixed(0)} '
'norm=${norm?.toStringAsFixed(3) ?? "null"} '
'peak=${_peakNorm.toStringAsFixed(3)} '
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}'
'\n${PenInputService.instance.debugSummary}');
}
void _onPointerHover(PointerHoverEvent event) {
if (_isStylus(event.kind)) {
_emitPenDebug(event);
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
// Fire edge-triggered button actions (undo / toggleTool) on hover so a
// mapped barrel press works without first touching down.
_dispatchHwButtonActions();
// Detect eraser (barrel button / inverted) while hovering.
_eraserActive = _isEraserSignal(event);
}
}
void _onPointerDown(PointerDownEvent event) {
if (event.kind == PointerDeviceKind.trackpad) return;
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
if (_isStylus(event.kind)) {
_emitPenDebug(event);
// Fire edge-triggered button actions for a direct pen-down (no prior
// hover); the native observer latched this contact's flags before Flutter
// synthesized this event (plan M1/M2).
_dispatchHwButtonActions();
}
_activePointers[event.pointer] = event.kind;
// A 2nd pointer arriving during a stroke = pinch/palm → cancel the stroke
// and let the InteractiveViewer take over pan/zoom.
if (_activePointers.length >= 2) {
if (_drawPointer != null) _cancelStroke();
return;
}
// Single pointer: decide draw vs pan. Eraser is on if this stylus down
// signals it (barrel button / inverted), or hover already flagged it.
if (_isStylus(event.kind)) {
_eraserActive = _eraserActive || _isEraserSignal(event);
} else {
_eraserActive = false;
}
if (_shouldDraw(event.kind)) {
_startStroke(event);
}
}
void _onPointerMove(PointerMoveEvent event) {
if (_isStylus(event.kind)) _emitPenDebug(event);
if (event.pointer != _drawPointer) return;
if (_activePointers.length >= 2) return; // pinch owns it
_extendStroke(event);
}
void _onPointerUp(PointerUpEvent event) {
final wasDrawer = event.pointer == _drawPointer;
_activePointers.remove(event.pointer);
if (wasDrawer) _endStroke();
}
void _onPointerCancel(PointerCancelEvent event) {
final wasDrawer = event.pointer == _drawPointer;
_activePointers.remove(event.pointer);
if (wasDrawer) _cancelStroke();
}
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
@override
void dispose() {
_eraserCursor.dispose();
2026-06-23 02:59:30 +08:00
_inkCache.dispose();
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
super.dispose();
}
@override
Widget build(BuildContext context) {
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
// The PEN never reaches PenInteractiveViewer's recognizer (it excludes
// stylus), so a stylus stroke can never be stolen as a pan. panEnabled only
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
// so a pinch re-enables pan/zoom immediately.
final panEnabled = _drawPointer == null;
2026-06-23 02:59:30 +08:00
// Mirror committed strokes into the revision-tracked store (only re-mirrors
// when the parent handed us a new list identity).
_syncStore();
final liveEditorStroke =
_liveStroke == null ? null : EditorStroke.fromPenStroke(_liveStroke!);
return Listener(
onPointerHover: _onPointerHover,
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
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
child: PenInteractiveViewer(
transformationController: widget.transformationController,
minScale: widget.minScale,
maxScale: widget.maxScale,
panEnabled: panEnabled,
scaleEnabled: true,
child: SizedBox(
width: widget.pageSize.width,
height: widget.pageSize.height,
child: Stack(
children: [
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
// PDF page bitmap. Wrapped in its own RepaintBoundary (W2) so the
// per-move live-ink repaints and the static-ink repaints never
// mark the page's raster layer dirty — isolating it from
// ink-driven repaints. (The definitive crisp-on-zoom / no-flash
// fix is the P0.5 page_tile DPI-on-settle double-buffer; this
// boundary is the safe, non-regressive interim per plan M3.)
Positioned.fill(
child: RepaintBoundary(child: widget.pageWidget),
),
2026-06-23 02:59:30 +08:00
// Committed ink (static layer, isolated repaint). Backed by the
// revision-gated ui.Picture cache (P0 step 3): unchanged across
// pinch/pan/live-move frames ⇒ cache hit ⇒ zero re-raster.
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
2026-06-23 02:59:30 +08:00
painter: render.StaticInkPainter(
hostId: _inkHostId,
store: _store,
pageSize: widget.pageSize,
2026-06-23 02:59:30 +08:00
cache: _inkCache,
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
thinning: widget.thinning,
),
),
),
),
// Eraser preview: faint outline on strokes about to be deleted +
// the eraser circle. Mounted only in eraser mode with a cursor.
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag 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>
2026-06-22 22:02:17 +08:00
if (_isEraserMode)
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
painter: EraserPreviewPainter(
strokes: widget.strokes,
cursor: _eraserCursor,
radius: widget.eraserRadius,
aspect: _pageAspect,
pageSize: widget.pageSize,
),
),
),
),
// Live ink (current stroke only, isolated repaint). Also carries
// the SHAPE tool's preview (built as a live PenStroke).
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
2026-06-23 02:59:30 +08:00
painter: render.LiveInkPainter(
live: liveEditorStroke,
pageSize: widget.pageSize,
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
thinning: widget.thinning,
),
),
),
),
// SELECT tool: bounding box around the selected stroke.
if (widget.tool == CanvasTool.select && _selectionBounds != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: SelectionOverlayPainter(
boundsNorm: _selectionBounds,
pageSize: widget.pageSize,
),
),
),
),
],
),
),
),
);
}
}