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.
88 lines
2.5 KiB
Dart
88 lines
2.5 KiB
Dart
// 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();
|
|
});
|
|
}
|
|
}
|