refactor(p0): extract pure input_arbiter from pen_canvas (step 4) + truth-table test
P0 step 4: the draw-vs-pan/zoom decision (single-pointer + device-kind + palm rejection + hardware-pan-button suppression) is lifted verbatim out of the PenCanvas StatefulWidget into pure functions in input/input_arbiter.dart, and pen_canvas now delegates _shouldDraw/_isStylus to them. Behavior-identical (same expressions), now decided by ONE unit-tested place. Adds test/input_arbiter_test.dart pinning the full truth table: stylus/mouse always draw, finger draws only with the toggle, >=2 pointers never draw (pinch owns it), hardware pan button suppresses, trackpad/unknown never draw. flutter analyze clean; 74/74 tests pass (+8). No live-path behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
|
import '../input/input_arbiter.dart' as arbiter;
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import 'ink_painters.dart';
|
import 'ink_painters.dart';
|
||||||
@@ -157,9 +158,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
// cancels an in-progress stroke regardless.)
|
// cancels an in-progress stroke regardless.)
|
||||||
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
|
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
|
||||||
|
|
||||||
bool _isStylus(PointerDeviceKind kind) =>
|
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
||||||
kind == PointerDeviceKind.stylus ||
|
|
||||||
kind == PointerDeviceKind.invertedStylus;
|
|
||||||
|
|
||||||
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
||||||
/// usable pressure range (then perfect_freehand simulates pressure).
|
/// usable pressure range (then perfect_freehand simulates pressure).
|
||||||
@@ -249,18 +248,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
return t == 0 ? null : t;
|
return t == 0 ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether the gesture currently forming should DRAW.
|
/// Decide whether the gesture currently forming should DRAW. Delegates to the
|
||||||
/// True iff exactly one active pointer AND (stylus OR finger-drawing on).
|
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
|
||||||
bool _shouldDraw(PointerDeviceKind kind) {
|
/// the tests can never disagree on the rule.
|
||||||
if (_activePointers.length != 1) return false;
|
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
|
||||||
// A hardware pen button mapped to `pan` suppresses drawing so the
|
activePointerCount: _activePointers.length,
|
||||||
// InteractiveViewer pans instead.
|
kind: kind,
|
||||||
if (_hwPanActive) return false;
|
fingerDrawingEnabled: _fingerDrawingEnabled,
|
||||||
if (_isStylus(kind)) return true;
|
hwPanActive: _hwPanActive,
|
||||||
if (kind == PointerDeviceKind.mouse) return true;
|
);
|
||||||
if (kind == PointerDeviceKind.touch) return _fingerDrawingEnabled;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Coordinate mapping ---------------------------------------------------
|
// --- Coordinate mapping ---------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
46
lib/editor/input/input_arbiter.dart
Normal file
46
lib/editor/input/input_arbiter.dart
Normal file
@@ -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;
|
||||||
|
}
|
||||||
73
test/input_arbiter_test.dart
Normal file
73
test/input_arbiter_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user