// 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'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_model.dart'; import '../engine/stroke_predictor.dart'; import '../engine/stroke_store.dart'; import '../input/input_arbiter.dart' as arbiter; import '../input/pen_config.dart'; import '../input/pressure_curve.dart'; import '../input/pen_input_service.dart'; import '../../diagnostics/pen_event_ring.dart'; import '../engine/shape_geometry.dart'; 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; 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, // The TEXT tool is PDF-editor-only for now (note/slide typed text is a // later increment); the note palette has no text button, so this mapping // is unreachable in practice — fall back to the pen so the switch stays // exhaustive without inventing a PenCanvas typed-text path. EditorToolKind.text => CanvasTool.pen, }; 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, this.thinning = kDefaultPenThinning, this.pressureGamma = kNaturalPressureGamma, this.pressureFloor = kNaturalPressureFloor, this.eraserRadius = kDefaultEraserRadius, this.eraserWholeStroke = false, 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 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 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? 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; /// 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; /// 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 createState() => _PenCanvasState(); } class _PenCanvasState extends State { /// Active (down) pointers by id → their device kind. Size == pointer count. final Map _activePointers = {}; /// The pointer id currently driving a stroke, or null. int? _drawPointer; /// In-progress stroke points (normalized). final List _livePoints = []; final StrokePredictor _predictor = StrokePredictor(); /// Count of real (non-predicted) points in [_livePoints]. int _realPointCount = 0; /// 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 /// mode / the pen is not near the page. A ValueNotifier so the preview layer /// repaints on cursor moves WITHOUT rebuilding the whole canvas every frame /// (the old per-move setState was the eraser-lag source). final ValueNotifier _eraserCursor = ValueNotifier(null); /// 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? _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]). /// /// Prefer native Win32 pressure from [PenInputService] when valid — Flutter's /// PointerEvent.pressure on Windows is often flat/useless while the driver /// still reports real 0..1024 via GetPointerPenInfo. double? _rawNormalizedPressure(PointerEvent event) { final hw = PenInputService.instance; if (hw.isActive && hw.current.pressureValid) { return hw.current.pressure.clamp(0.0, 1.0); } 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; } /// 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 || 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) { final draw = arbiter.shouldDraw( activePointerCount: _activePointers.length, kind: kind, fingerDrawingEnabled: _fingerDrawingEnabled, hwPanActive: _hwPanActive, ); PenEventRing.instance.recordArbiter( activeCount: _activePointers.length, deviceKind: kind.name, draw: draw, fingerDrawing: _fingerDrawingEnabled, ); return draw; } // --- Coordinate mapping --------------------------------------------------- /// Map a global pointer position into normalized page coords using the /// shared transform (inverse) and this widget's geometry. 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; return PenPoint(nx, ny, pressure, tilt: tilt); } // --- Stroke lifecycle ----------------------------------------------------- void _startStroke(PointerDownEvent event) { _drawPointer = event.pointer; _livePoints.clear(); _realPointCount = 0; _predictor.reset(); _shapeStart = null; _selectLast = null; _selectDragging = false; final p = _toNormalized(event.position, _normalizedPressure(event), tilt: _tiltFor(event)); if (_eraserActive || widget.tool == CanvasTool.eraser) { _eraserCursor.value = p; _eraseAt(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; } // 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); _realPointCount = _livePoints.length; } _updateLiveStroke(); } void _extendStroke(PointerMoveEvent event) { final p = _toNormalized(event.position, _normalizedPressure(event), tilt: _tiltFor(event)); if (p == null) return; if (_eraserActive || widget.tool == CanvasTool.eraser) { _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; } // Drop previous predicted tip before appending the real sample. if (_livePoints.length > _realPointCount) { _livePoints.removeRange(_realPointCount, _livePoints.length); } _livePoints.add(p); _realPointCount = _livePoints.length; final pred = _predictor.observe(Offset(p.x, p.y), p.pressure ?? 0.5); if (pred != null) { _livePoints.add(PenPoint( pred.offset.dx.clamp(0.0, 1.0), pred.offset.dy.clamp(0.0, 1.0), pred.pressure, tilt: p.tilt, )); } _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: kShapeBrush, )); } } else if (tool == CanvasTool.select) { // Nothing to commit on release: selection + moves were applied live. } else if (!wasEraser && _livePoints.isNotEmpty) { // Never commit predicted tips — only real digitizer samples. if (_livePoints.length > _realPointCount) { _livePoints.removeRange(_realPointCount, _livePoints.length); } 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(); _realPointCount = 0; _predictor.reset(); _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: kShapeBrush, ); }); } /// Discard the in-progress stroke without committing (palm/2nd-finger). void _cancelStroke() { _drawPointer = null; _livePoints.clear(); _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 [] : 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 --------------------------------------------------- /// 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; 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)} ' 'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}' '\n${PenInputService.instance.debugSummary}'); } void _onPointerHover(PointerHoverEvent event) { if (_isStylus(event.kind)) { _emitPenDebug(event); // 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; 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(); } @override void dispose() { _eraserCursor.dispose(); _inkCache.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // 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; // 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, 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: [ // 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), ), // 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( painter: render.StaticInkPainter( hostId: _inkHostId, store: _store, pageSize: widget.pageSize, cache: _inkCache, thinning: widget.thinning, ), ), ), ), // Eraser preview: faint outline on strokes about to be deleted + // the eraser circle. Mounted only in eraser mode with a cursor. 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( painter: render.LiveInkPainter( live: liveEditorStroke, pageSize: widget.pageSize, 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, ), ), ), ), ], ), ), ), ); } }