Compare commits
4 Commits
682907614d
...
eca5141372
| Author | SHA1 | Date | |
|---|---|---|---|
| eca5141372 | |||
| f64e6561a0 | |||
| d50087247c | |||
| a48c0e7e56 |
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
117
test/export_geometry_test.dart
Normal file
117
test/export_geometry_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
137
test/save_scheduler_test.dart
Normal file
137
test/save_scheduler_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user