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>
This commit is contained in:
2026-06-23 02:59:30 +08:00
parent f64e6561a0
commit eca5141372
4 changed files with 152 additions and 12 deletions

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