diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 65db3b3..f0a50f6 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -23,6 +23,7 @@ import 'package:flutter/material.dart'; import '../engine/stroke_eraser.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; +import '../input/input_arbiter.dart' as arbiter; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import 'ink_painters.dart'; @@ -157,9 +158,7 @@ class _PenCanvasState extends State { // cancels an in-progress stroke regardless.) bool get _fingerDrawingEnabled => widget.allowFingerDrawing; - bool _isStylus(PointerDeviceKind kind) => - kind == PointerDeviceKind.stylus || - kind == PointerDeviceKind.invertedStylus; + bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind); /// Normalize stylus pressure to [0,1], or null when the device reports no /// usable pressure range (then perfect_freehand simulates pressure). @@ -249,18 +248,15 @@ class _PenCanvasState extends State { return t == 0 ? null : t; } - /// Decide whether the gesture currently forming should DRAW. - /// True iff exactly one active pointer AND (stylus OR finger-drawing on). - bool _shouldDraw(PointerDeviceKind kind) { - if (_activePointers.length != 1) return false; - // A hardware pen button mapped to `pan` suppresses drawing so the - // InteractiveViewer pans instead. - if (_hwPanActive) return false; - if (_isStylus(kind)) return true; - if (kind == PointerDeviceKind.mouse) return true; - if (kind == PointerDeviceKind.touch) return _fingerDrawingEnabled; - return false; - } + /// 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 --------------------------------------------------- diff --git a/lib/editor/input/input_arbiter.dart b/lib/editor/input/input_arbiter.dart new file mode 100644 index 0000000..e46f3f6 --- /dev/null +++ b/lib/editor/input/input_arbiter.dart @@ -0,0 +1,46 @@ +// lib/editor/input/input_arbiter.dart +// +// Pure draw-vs-pan/zoom arbitration for the pen-first canvas (P0 step 4 — +// extracted verbatim from `pen_canvas.dart` so the make-or-break gesture rules +// are decided by ONE testable place rather than inline in a StatefulWidget). +// +// The model (clean-room from Saber, proven live): +// - A DRAW gesture is exactly ONE active pointer that is a stylus / inverted +// stylus / mouse, OR (when the finger-drawing toggle is on) a single finger. +// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw. +// - Palm rejection: a finger never draws unless the user explicitly enabled +// finger-drawing — so a resting palm pans (or is ignored) instead of marking. +// - A hardware pen button mapped to `pan` suppresses drawing so the shared +// InteractiveViewer pans instead. +// +// These are PURE functions (no widget/IO state) so the whole truth table is +// unit-tested; `pen_canvas.dart` owns the live pointer map and delegates the +// decisions here. + +import 'package:flutter/gestures.dart' show PointerDeviceKind; + +/// Whether [kind] is a pen (tip or flipped eraser end). +bool isStylusKind(PointerDeviceKind kind) => + kind == PointerDeviceKind.stylus || + kind == PointerDeviceKind.invertedStylus; + +/// Decide whether the gesture currently forming should DRAW. +/// +/// True iff there is exactly one active pointer, drawing is not suppressed by a +/// hardware pan button, and the pointer is a draw device: +/// - stylus / inverted stylus → always draws, +/// - mouse → always draws (desktop authoring), +/// - touch → draws only when [fingerDrawingEnabled] (else it pans / is palm). +bool shouldDraw({ + required int activePointerCount, + required PointerDeviceKind kind, + required bool fingerDrawingEnabled, + required bool hwPanActive, +}) { + if (activePointerCount != 1) return false; + if (hwPanActive) return false; + if (isStylusKind(kind)) return true; + if (kind == PointerDeviceKind.mouse) return true; + if (kind == PointerDeviceKind.touch) return fingerDrawingEnabled; + return false; +} diff --git a/test/input_arbiter_test.dart b/test/input_arbiter_test.dart new file mode 100644 index 0000000..39e4484 --- /dev/null +++ b/test/input_arbiter_test.dart @@ -0,0 +1,73 @@ +// Truth-table tests for the pure draw-vs-pan arbitration (P0 step 4/8). These +// pin the make-or-break gesture rules (palm rejection, finger toggle, hardware +// pan button, multi-pointer = pinch) so a refactor of pen_canvas can't silently +// change behavior. + +import 'package:flutter/gestures.dart' show PointerDeviceKind; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/input/input_arbiter.dart'; + +void main() { + group('isStylusKind', () { + test('stylus and invertedStylus are pens; others are not', () { + expect(isStylusKind(PointerDeviceKind.stylus), isTrue); + expect(isStylusKind(PointerDeviceKind.invertedStylus), isTrue); + expect(isStylusKind(PointerDeviceKind.touch), isFalse); + expect(isStylusKind(PointerDeviceKind.mouse), isFalse); + expect(isStylusKind(PointerDeviceKind.trackpad), isFalse); + expect(isStylusKind(PointerDeviceKind.unknown), isFalse); + }); + }); + + group('shouldDraw', () { + bool draw( + int count, + PointerDeviceKind kind, { + bool finger = false, + bool hwPan = false, + }) => + shouldDraw( + activePointerCount: count, + kind: kind, + fingerDrawingEnabled: finger, + hwPanActive: hwPan, + ); + + test('a single stylus always draws', () { + expect(draw(1, PointerDeviceKind.stylus), isTrue); + expect(draw(1, PointerDeviceKind.invertedStylus), isTrue); + }); + + test('a single mouse draws (desktop authoring)', () { + expect(draw(1, PointerDeviceKind.mouse), isTrue); + }); + + test('a single finger draws ONLY when finger-drawing is enabled', () { + expect(draw(1, PointerDeviceKind.touch, finger: false), isFalse); + expect(draw(1, PointerDeviceKind.touch, finger: true), isTrue); + }); + + test('>= 2 pointers never draw (pinch owns it), even a stylus', () { + expect(draw(2, PointerDeviceKind.stylus), isFalse); + expect(draw(2, PointerDeviceKind.touch, finger: true), isFalse); + expect(draw(3, PointerDeviceKind.mouse), isFalse); + }); + + test('zero pointers never draw', () { + expect(draw(0, PointerDeviceKind.stylus), isFalse); + }); + + test('a hardware pan button suppresses drawing for any device', () { + expect(draw(1, PointerDeviceKind.stylus, hwPan: true), isFalse); + expect(draw(1, PointerDeviceKind.mouse, hwPan: true), isFalse); + expect(draw(1, PointerDeviceKind.touch, finger: true, hwPan: true), + isFalse); + }); + + test('trackpad / unknown never draw', () { + expect(draw(1, PointerDeviceKind.trackpad, finger: true), isFalse); + expect(draw(1, PointerDeviceKind.unknown, finger: true), isFalse); + }); + }); +}