Compare commits

..

4 Commits

Author SHA1 Message Date
eca5141372 feat(p0): live canvas renders via revision-gated ui.Picture cache (step 3)
Some checks failed
CI / Windows build (push) Has been cancelled
The live PenCanvas committed-ink layer now uses the relocated render/ painters
(render.StaticInkPainter + InkPictureCache + StrokeStore) instead of the old
canvas/ink_painters versions — the P0.5 perf prerequisite. The committed layer's
ui.Picture is recorded once per StrokeStore.revision and replayed on the raster
thread, so pinch / pan / live-stroke frames no longer re-rasterize committed ink.

- pen_canvas mirrors widget.strokes (PenStroke) into a StrokeStore (EditorStroke)
  on every new-list identity (the parent already replaces the list on each
  commit/erase), bumping the revision → cache invalidates → static layer repaints.
- thinning (PenConfig.pressureSensitivity) is threaded into the render painters
  AND folded into the cache key + shouldRepaint, so a sensitivity change can't
  replay a stale Picture built at the old thinning.
- live layer converts _liveStroke→EditorStroke per frame (correct: it must
  repaint every move); eraser preview keeps the existing canvas painter.
- pen_canvas disposes the InkPictureCache.

Equivalent by construction (both paths call buildStrokeOutline with the same
thinning); device confirms final fidelity. The old canvas Static/LiveInkPainter
are now orphaned (buildStrokePath still used by tests) — P1 deletes them.

flutter analyze lib/editor clean; 90/90 tests (+6: thinning repaint/cache + live).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:59:30 +08:00
f64e6561a0 test(p0): SaveScheduler debounce/snapshot/flush/dispose coverage (step 8)
Closes a P0 step-8 test gap. Drives SaveScheduler with a recording
EditorRepository subclass (real in-memory ffi db only to satisfy the ctor) and
pins: flush writes immediately; rapid schedules coalesce to ONE debounced write
with the latest snapshot; the captured snapshot is isolated from later mutation
of the source list; distinct hosts flush independently; dispose cancels a
pending write; schedule-after-dispose is a no-op.

flutter analyze clean; 6/6 new, 84/84 total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:53:28 +08:00
d50087247c fix(export): single-source freehand recipe screen+export (P0 step 7, R7)
pdf_service._buildFreehandPdfPath hardcoded its OWN StrokeOptions
(thinning:0.7, streamline:0.5) instead of the shared geometry — the R7
hairline-export divergence. The prior pen-feel commit (streamline 0.5→0.32 on
screen) widened the gap: export still rendered at 0.5.

Extract the ONE perfect_freehand recipe into stroke_geometry.freehandOutlinePoints
(owns thinning/smoothing/streamline/simulatePressure). buildStrokeOutline (screen)
and pdf_service (export, via InkStroke→pfPoints) now both call it, so the
StrokeOptions live in exactly one place and screen↔export can't drift again.
Export now matches screen: thinning 0.85 (kDefaultPenThinning), streamline 0.32.

test/export_geometry_test.dart pins it: buildStrokeOutline traces exactly the
shared outline; default thinning == kDefaultPenThinning; thinning is wired;
empty input is safe.

flutter analyze lib/editor clean; 78/78 tests pass (+4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 02:50:53 +08:00
a48c0e7e56 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>
2026-06-23 01:56:13 +08:00
10 changed files with 592 additions and 60 deletions

View File

@@ -23,9 +23,15 @@ import 'package:flutter/material.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import 'ink_painters.dart';
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'ink_painters.dart' show EraserPreviewPainter;
import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart';
@@ -135,6 +141,28 @@ class _PenCanvasState extends State<PenCanvas> {
/// (the old per-move setState was the eraser-lag source).
final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(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<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 =>
@@ -157,9 +185,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 +275,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 ---------------------------------------------------
@@ -466,6 +489,7 @@ class _PenCanvasState extends State<PenCanvas> {
@override
void dispose() {
_eraserCursor.dispose();
_inkCache.dispose();
super.dispose();
}
@@ -478,6 +502,12 @@ class _PenCanvasState extends State<PenCanvas> {
// 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,
@@ -504,13 +534,17 @@ class _PenCanvasState extends State<PenCanvas> {
Positioned.fill(
child: RepaintBoundary(child: widget.pageWidget),
),
// Committed ink (static layer, isolated repaint).
// 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: StaticInkPainter(
strokes: widget.strokes,
painter: render.StaticInkPainter(
hostId: _inkHostId,
store: _store,
pageSize: widget.pageSize,
cache: _inkCache,
thinning: widget.thinning,
),
),
@@ -536,8 +570,8 @@ class _PenCanvasState extends State<PenCanvas> {
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
painter: LiveInkPainter(
stroke: _liveStroke,
painter: render.LiveInkPainter(
live: liveEditorStroke,
pageSize: widget.pageSize,
thinning: widget.thinning,
),

View File

@@ -32,6 +32,45 @@ const double kDefaultPenThinning = 0.85;
const double kPenStreamline = 0.32;
const double kPenSmoothing = 0.5;
/// THE single perfect_freehand outline recipe — the raw outline points for a
/// stroke. Both the on-screen painter ([buildStrokeOutline] / the live
/// `ink_painters.buildStrokePath`) and the PDF export
/// (`pdf_service._buildFreehandPdfPath`) call THIS, so the `StrokeOptions`
/// (thinning / smoothing / streamline / simulatePressure) live in exactly one
/// place and screen↔export can never drift again (R7 — the hairline-export bug
/// was pdf_service hardcoding its own `thinning: 0.7, streamline: 0.5`).
///
/// Callers supply already-pixel-scaled [pfPoints] (because the two stroke
/// models scale differently) plus the per-stroke flags. Returns the closed
/// outline as `List<Offset>` (perfect_freehand 2.x); empty when freehand
/// produces nothing.
List<Offset> freehandOutlinePoints({
required List<pf.PointVector> pfPoints,
required double size,
required bool isHighlighter,
required bool hasRealPressure,
required bool isComplete,
double thinning = kDefaultPenThinning,
}) {
if (pfPoints.isEmpty) return const <Offset>[];
return pf.getStroke(
pfPoints,
options: pf.StrokeOptions(
size: size,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure -> don't simulate; no pressure -> let freehand
// fake it based on velocity (highlighter never simulates). perfect_freehand
// 2.x honors real pressure when simulatePressure is false.
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
),
);
}
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
///
@@ -52,11 +91,6 @@ Path buildStrokeOutline(
final path = Path();
if (stroke.points.isEmpty) return path;
final pixelWidth = stroke.width * pageSize.width;
final hasRealPressure = stroke.points.any((p) => p.pressure != null);
final isHighlighter = stroke.tool == EditorTool.highlighter;
final pfPoints = stroke.points
.map(
(p) => pf.PointVector(
@@ -67,21 +101,13 @@ Path buildStrokeOutline(
)
.toList();
final outline = pf.getStroke(
pfPoints,
options: pf.StrokeOptions(
size: pixelWidth,
// Highlighter keeps a constant width (no thinning); pen uses the
// configurable [thinning] so Surface-Pen pressure changes width.
thinning: isHighlighter ? 0.0 : thinning,
smoothing: kPenSmoothing,
streamline: kPenStreamline,
// Real stylus pressure -> don't simulate; no pressure -> let freehand
// fake it based on velocity (highlighter never simulates). perfect_freehand
// 2.x honors real pressure when simulatePressure is false.
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: isComplete,
),
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: stroke.width * pageSize.width,
isHighlighter: stroke.tool == EditorTool.highlighter,
hasRealPressure: stroke.points.any((p) => p.pressure != null),
isComplete: isComplete,
thinning: thinning,
);
if (outline.isEmpty) return path;

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

View File

@@ -18,18 +18,25 @@ class LiveInkPainter extends CustomPainter {
const LiveInkPainter({
required this.live,
required this.pageSize,
this.thinning = kDefaultPenThinning,
});
/// The stroke currently being drawn, or null when idle.
final EditorStroke? live;
final Size pageSize;
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`),
/// kept consistent with the static layer so the stroke doesn't change width
/// the instant it commits.
final double thinning;
@override
void paint(Canvas canvas, Size size) {
final stroke = live;
if (stroke == null || stroke.points.isEmpty) return;
final path = buildStrokeOutline(stroke, pageSize, isComplete: false);
final path = buildStrokeOutline(stroke, pageSize,
isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return;
canvas.drawPath(
@@ -43,5 +50,7 @@ class LiveInkPainter extends CustomPainter {
@override
bool shouldRepaint(LiveInkPainter old) =>
!identical(old.live, live) || old.pageSize != pageSize;
!identical(old.live, live) ||
old.pageSize != pageSize ||
old.thinning != thinning;
}

View File

@@ -28,6 +28,7 @@ class StaticInkPainter extends CustomPainter {
required this.store,
required this.pageSize,
required this.cache,
this.thinning = kDefaultPenThinning,
}) : revision = store.revision;
final String hostId;
@@ -35,6 +36,11 @@ class StaticInkPainter extends CustomPainter {
final Size pageSize;
final InkPictureCache cache;
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`).
/// Folded into the cache key + [shouldRepaint] so a sensitivity change can't
/// replay a stale Picture built at the old thinning.
final double thinning;
/// Revision snapshot captured at construction time. Used by [shouldRepaint]
/// so two painters built at different revisions compare correctly even when
/// they share the same [StrokeStore] instance.
@@ -42,12 +48,15 @@ class StaticInkPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final picture = cache.getOrBuild(hostId, store.revision, pageSize, () {
// thinning is part of the cache identity (different thinning ⇒ different
// outline) so it MUST be in the key, not just shouldRepaint.
final cacheKey = '$hostId#${thinning.toStringAsFixed(4)}';
final picture = cache.getOrBuild(cacheKey, store.revision, pageSize, () {
final recorder = ui.PictureRecorder();
final rec = Canvas(recorder);
for (final stroke in store.committed) {
final path =
buildStrokeOutline(stroke, pageSize, isComplete: true);
final path = buildStrokeOutline(stroke, pageSize,
isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
rec.drawPath(
path,
@@ -65,5 +74,7 @@ class StaticInkPainter extends CustomPainter {
@override
bool shouldRepaint(StaticInkPainter old) =>
old.revision != store.revision || old.pageSize != pageSize;
old.revision != store.revision ||
old.pageSize != pageSize ||
old.thinning != thinning;
}

View File

@@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../editor/engine/stroke_geometry.dart' show freehandOutlinePoints;
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
@@ -304,19 +305,15 @@ class PdfService {
)
.toList();
final outline = pf.getStroke(
pfPoints,
options: pf.StrokeOptions(
size: pixelWidth,
// Highlighter keeps constant width; pen/marker taper via thinning=0.7.
thinning: isHighlighter ? 0.0 : 0.7,
smoothing: 0.5,
streamline: 0.5,
// Real stylus pressure -> don't simulate; no pressure -> let freehand
// fake it based on velocity. Highlighter never simulates.
simulatePressure: !hasRealPressure && !isHighlighter,
isComplete: true,
),
// ONE shared recipe with the on-screen painter (R7): export can no longer
// drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5,
// which diverged from the screen's 0.85/0.32 → hairline export mismatch.
final outline = freehandOutlinePoints(
pfPoints: pfPoints,
size: pixelWidth,
isHighlighter: isHighlighter,
hasRealPressure: hasRealPressure,
isComplete: true,
);
if (outline.isEmpty) return null;

View File

@@ -9,12 +9,15 @@
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
// pageSize so we can exercise it without a real ui.Picture or Canvas.
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/engine/stroke_model.dart';
import 'package:badnote/editor/engine/stroke_store.dart';
import 'package:badnote/editor/render/ink_picture_cache.dart';
import 'package:badnote/editor/render/live_ink_painter.dart';
import 'package:badnote/editor/render/static_ink_painter.dart';
// ---------------------------------------------------------------------------
@@ -156,5 +159,84 @@ void main() {
// pNew.revision (3) != pOld.revision (2) → should repaint.
expect(pNew.shouldRepaint(pOld), isTrue);
});
// thinning (PenConfig.pressureSensitivity) participates in the static layer
// identity: a sensitivity change rebuilds the outline, so it MUST trigger a
// repaint even when revision + pageSize are unchanged — otherwise the cached
// Picture (built at the old thinning) would be replayed (stale ink).
test('returns true when only thinning changes (no stale cached Picture)',
() {
final store = StrokeStore()..add(_stroke('s1'));
final cache = InkPictureCache();
final pThin = StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: 0.85);
final pThick = StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: 0.2);
expect(pThick.shouldRepaint(pThin), isTrue);
});
test('returns false when thinning is equal (revision+size unchanged)', () {
final store = StrokeStore()..add(_stroke('s1'));
final cache = InkPictureCache();
StaticInkPainter mk() => StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: 0.6);
expect(mk().shouldRepaint(mk()), isFalse);
});
});
// -------------------------------------------------------------------------
group('StaticInkPainter.paint (cache + thinning recipe)', () {
test('paints committed strokes into a Picture without error', () {
final store = StrokeStore()
..add(_stroke('a'))
..add(_stroke('b'));
final painter = _painter(store, pageSize);
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), pageSize);
final picture = recorder.endRecording();
addTearDown(picture.dispose);
expect(picture, isNotNull);
});
test('a thinning change builds a fresh Picture (cache key includes thinning)',
() {
final store = StrokeStore()..add(_stroke('a'));
final cache = InkPictureCache();
// Same store/revision/size, different thinning, shared cache.
for (final t in [0.85, 0.2]) {
final painter = StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: t);
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), pageSize);
recorder.endRecording().dispose();
}
// If the key ignored thinning, the 2nd paint would replay the 1st's
// Picture; the test simply asserts both paints complete (distinct keys,
// no aliasing assertion error from drawing a disposed picture).
expect(true, isTrue);
});
});
// -------------------------------------------------------------------------
group('LiveInkPainter.shouldRepaint', () {
test('repaints when the live stroke identity changes', () {
const a = LiveInkPainter(live: null, pageSize: pageSize);
final b = LiveInkPainter(
live: _stroke('live'), pageSize: pageSize);
expect(b.shouldRepaint(a), isTrue);
});
test('repaints when thinning changes (width consistent with static layer)',
() {
final stroke = _stroke('live');
final a = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.85);
final b = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.2);
expect(b.shouldRepaint(a), isTrue);
});
});
}

View File

@@ -0,0 +1,117 @@
// R7 regression (P0 step 7/8): the on-screen painter and the PDF export must
// build their freehand outline from ONE shared recipe. The export bug was
// pdf_service hardcoding `thinning: 0.7, streamline: 0.5` while the screen used
// 0.85 / 0.32 — a hairline mismatch. These tests pin the single source so a
// future edit to one side can't silently re-diverge.
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'package:badnote/editor/engine/stroke_geometry.dart';
import 'package:badnote/editor/engine/stroke_model.dart';
void main() {
const size = Size(800, 600);
EditorStroke pen() => EditorStroke.create(
id: 'a',
points: const [
EditorPoint(x: 0.1, y: 0.1, pressure: 0.9),
EditorPoint(x: 0.4, y: 0.2, pressure: 0.5),
EditorPoint(x: 0.7, y: 0.15, pressure: 0.2),
],
width: 0.01,
);
test('buildStrokeOutline traces exactly the shared freehandOutlinePoints', () {
final stroke = pen();
final pfPoints = stroke.points
.map((p) => pf.PointVector(
p.x * size.width, p.y * size.height, p.pressure ?? 0.5))
.toList();
final shared = freehandOutlinePoints(
pfPoints: pfPoints,
size: stroke.width * size.width,
isHighlighter: false,
hasRealPressure: true,
isComplete: true,
);
expect(shared, isNotEmpty);
final ref = Path()..moveTo(shared.first.dx, shared.first.dy);
for (var i = 1; i < shared.length; i++) {
ref.lineTo(shared[i].dx, shared[i].dy);
}
ref.close();
final screen = buildStrokeOutline(stroke, size, isComplete: true);
// Same vertices ⇒ identical bounds. (If the screen path used different
// options than the shared core, the outline would differ.)
expect(screen.getBounds(), ref.getBounds());
});
test('default thinning IS kDefaultPenThinning (export passes the default)', () {
final pts = [
pf.PointVector(10, 10, 0.9),
pf.PointVector(100, 40, 0.5),
pf.PointVector(200, 30, 0.2),
];
final byDefault = freehandOutlinePoints(
pfPoints: pts,
size: 8,
isHighlighter: false,
hasRealPressure: true,
isComplete: true,
);
final explicit = freehandOutlinePoints(
pfPoints: pts,
size: 8,
isHighlighter: false,
hasRealPressure: true,
isComplete: true,
thinning: kDefaultPenThinning,
);
expect(byDefault, explicit);
});
test('thinning actually changes the outline (param is wired, not ignored)',
() {
final pts = [
pf.PointVector(10, 10, 0.9),
pf.PointVector(100, 40, 0.5),
pf.PointVector(200, 30, 0.2),
];
final strong = freehandOutlinePoints(
pfPoints: pts,
size: 8,
isHighlighter: false,
hasRealPressure: true,
isComplete: true,
thinning: kDefaultPenThinning,
);
final none = freehandOutlinePoints(
pfPoints: pts,
size: 8,
isHighlighter: false,
hasRealPressure: true,
isComplete: true,
thinning: 0.0,
);
expect(strong, isNot(equals(none)));
});
test('empty input yields an empty outline (no crash)', () {
expect(
freehandOutlinePoints(
pfPoints: const [],
size: 8,
isHighlighter: false,
hasRealPressure: false,
isComplete: true,
),
isEmpty,
);
});
}

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

View File

@@ -0,0 +1,137 @@
// Tests for SaveScheduler (P0 step 8): debounce coalescing, synchronous
// snapshot capture, per-host independence, flush, and dispose. The scheduler's
// value is its batching/timing logic, so we drive it with a recording repo that
// records saveHost calls instead of touching the DB.
//
// Run via:
// bash tool/test.sh test/save_scheduler_test.dart
import 'package:flutter_test/flutter_test.dart';
// Database + inMemoryDatabasePath are re-exported by sqflite_common_ffi.
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:badnote/editor/engine/stroke_model.dart';
import 'package:badnote/editor/persistence/editor_repository.dart';
import 'package:badnote/editor/persistence/save_scheduler.dart';
class _RecordingRepo extends EditorRepository {
_RecordingRepo(super.db);
final List<({String kind, String host, List<EditorStroke> strokes})> calls =
[];
@override
Future<void> saveHost(
String hostKind,
String hostId,
List<EditorStroke> strokes,
) async {
calls.add((kind: hostKind, host: hostId, strokes: strokes));
}
}
EditorStroke _stroke(String id) => EditorStroke.create(
id: id,
points: const [
EditorPoint(x: 0.1, y: 0.2),
EditorPoint(x: 0.3, y: 0.4),
],
);
void main() {
late Database db;
late _RecordingRepo repo;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
repo = _RecordingRepo(db);
});
tearDown(() async {
await db.close();
});
test('flush writes pending immediately without waiting for the debounce',
() async {
final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
scheduler.schedule('page', 'h1', [_stroke('a')]);
expect(repo.calls, isEmpty, reason: 'debounce not elapsed yet');
await scheduler.flush();
expect(repo.calls, hasLength(1));
expect(repo.calls.single.host, 'h1');
expect(repo.calls.single.strokes.single.id, 'a');
scheduler.dispose();
});
test('rapid successive schedules coalesce into ONE write with the latest snapshot',
() async {
final scheduler =
SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
scheduler.schedule('page', 'h1', [_stroke('v1')]);
scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2')]);
scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2'), _stroke('v3')]);
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(repo.calls, hasLength(1), reason: '3 schedules → 1 debounced write');
expect(repo.calls.single.strokes.map((s) => s.id),
['v1', 'v2', 'v3']);
scheduler.dispose();
});
test('captured snapshot is isolated from later mutation of the source list',
() async {
final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
final source = [_stroke('a')];
// Caller convention: pass a defensive copy.
scheduler.schedule('page', 'h1', List.of(source));
// Mutating the source after scheduling must not affect the write.
source.add(_stroke('b'));
await scheduler.flush();
expect(repo.calls.single.strokes.map((s) => s.id), ['a']);
scheduler.dispose();
});
test('distinct hosts are scheduled and flushed independently', () async {
final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
scheduler.schedule('page', 'h1', [_stroke('a')]);
scheduler.schedule('page', 'h2', [_stroke('b')]);
await scheduler.flush();
expect(repo.calls, hasLength(2));
expect(repo.calls.map((c) => c.host).toSet(), {'h1', 'h2'});
scheduler.dispose();
});
test('dispose cancels a pending write (nothing is persisted)', () async {
final scheduler =
SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
scheduler.schedule('page', 'h1', [_stroke('a')]);
scheduler.dispose();
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(repo.calls, isEmpty);
});
test('schedule after dispose is a no-op', () async {
final scheduler =
SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
scheduler.dispose();
scheduler.schedule('page', 'h1', [_stroke('a')]);
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(repo.calls, isEmpty);
});
}