feat(engine): P0 stroke engine + persistence
Per the full-refactor plan §9 (input-independent half of P0): - engine: canonical EditorStroke (lossless InkStroke round-trip) + stroke_geometry (single getStroke outline) + revision-gated StrokeStore - render: static/live ink painters + ink_picture_cache (revision-keyed) + annotation_layer (RepaintBoundary) - persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write (UPSERT changed / DELETE removed in one txn; id-set after commit) + save_scheduler - pdf_service export now FILLS the getStroke outline (R7 hairline fix) Not yet wired into the live editor (input relocation pending pen-pressure diagnostic). 28 new tests pass.
This commit is contained in:
87
lib/editor/render/ink_picture_cache.dart
Normal file
87
lib/editor/render/ink_picture_cache.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
// lib/editor/render/ink_picture_cache.dart
|
||||
//
|
||||
// Bounded LRU cache of ui.Picture objects keyed by "hostId:revision".
|
||||
//
|
||||
// Resolution-independent: ink is vector, so a single Picture is valid at any
|
||||
// zoom level. There are NO DPI buckets.
|
||||
//
|
||||
// Evicted Pictures are disposed via a post-frame callback so Flutter's raster
|
||||
// thread is never asked to delete a Picture it may still be reading.
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Bounded LRU cache of [ui.Picture]s keyed by a string (typically
|
||||
/// `"$hostId:$revision"`).
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final picture = cache.getOrBuild(hostId, store.revision, size, () {
|
||||
/// final recorder = ui.PictureRecorder();
|
||||
/// final canvas = ui.Canvas(recorder);
|
||||
/// // … draw …
|
||||
/// return recorder.endRecording();
|
||||
/// });
|
||||
/// canvas.drawPicture(picture);
|
||||
/// ```
|
||||
class InkPictureCache {
|
||||
InkPictureCache({int maxSize = 12}) : _maxSize = maxSize;
|
||||
|
||||
final int _maxSize;
|
||||
|
||||
// LinkedHashMap preserves insertion order; we move accessed entries to the
|
||||
// back so the front is always the least-recently used.
|
||||
final LinkedHashMap<String, ui.Picture> _cache =
|
||||
LinkedHashMap<String, ui.Picture>();
|
||||
|
||||
/// Returns a cached [ui.Picture] for [key], or calls [build] to create one.
|
||||
///
|
||||
/// The [key] should encode all inputs that affect the picture content (host
|
||||
/// id + revision, at minimum). [size] and [build] are only used on a cache
|
||||
/// miss.
|
||||
ui.Picture getOrBuild(
|
||||
String hostId,
|
||||
int revision,
|
||||
ui.Size size,
|
||||
ui.Picture Function() build,
|
||||
) {
|
||||
final key = '$hostId:$revision';
|
||||
|
||||
if (_cache.containsKey(key)) {
|
||||
// Promote to most-recently-used by reinserting at the back.
|
||||
final pic = _cache.remove(key)!;
|
||||
_cache[key] = pic;
|
||||
return pic;
|
||||
}
|
||||
|
||||
final picture = build();
|
||||
_cache[key] = picture;
|
||||
|
||||
// Evict least-recently-used entries beyond the cap.
|
||||
while (_cache.length > _maxSize) {
|
||||
final lruKey = _cache.keys.first;
|
||||
final evicted = _cache.remove(lruKey)!;
|
||||
_disposeDeferred(evicted);
|
||||
}
|
||||
|
||||
return picture;
|
||||
}
|
||||
|
||||
/// Disposes all cached Pictures, deferring the actual disposal to a
|
||||
/// post-frame callback so any in-flight raster work can complete.
|
||||
void dispose() {
|
||||
final pictures = List<ui.Picture>.from(_cache.values);
|
||||
_cache.clear();
|
||||
for (final pic in pictures) {
|
||||
_disposeDeferred(pic);
|
||||
}
|
||||
}
|
||||
|
||||
static void _disposeDeferred(ui.Picture picture) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
picture.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user