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:
2026-06-23 01:56:13 +08:00
parent 682907614d
commit a48c0e7e56
3 changed files with 130 additions and 15 deletions

View File

@@ -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<PenCanvas> {
// 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<PenCanvas> {
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 ---------------------------------------------------

View 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;
}