Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: test-${{ github.ref }}
Expand All @@ -17,13 +16,13 @@ jobs:
name: Test
timeout-minutes: 12
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Install FVM
shell: bash
run: |
curl -fsSL https://fvm.app/install.sh | bash
echo "/home/runner/fvm/bin" >> $GITHUB_PATH
echo "/home/runner/fvm/bin" >> "$GITHUB_PATH"

- uses: kuhnroyal/flutter-fvm-config-action@v2
id: fvm-config-action
Expand Down Expand Up @@ -71,7 +70,7 @@ jobs:
name: Integration Tests
timeout-minutes: 25
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4

- name: Install Linux Desktop Dependencies
timeout-minutes: 8
Expand All @@ -87,7 +86,7 @@ jobs:
shell: bash
run: |
curl -fsSL https://fvm.app/install.sh | bash
echo "/home/runner/fvm/bin" >> $GITHUB_PATH
echo "/home/runner/fvm/bin" >> "$GITHUB_PATH"

- uses: kuhnroyal/flutter-fvm-config-action@v2
id: fvm-config-action
Expand Down Expand Up @@ -137,7 +136,7 @@ jobs:
shell: bash
run: |
curl -fsSL https://fvm.app/install.sh | bash
echo "/home/runner/fvm/bin" >> $GITHUB_PATH
echo "/home/runner/fvm/bin" >> "$GITHUB_PATH"

- uses: kuhnroyal/flutter-fvm-config-action@v2
id: fvm-config-action
Expand Down
99 changes: 84 additions & 15 deletions packages/superdeck/lib/src/capture/slide_capture_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -206,19 +206,33 @@ class SlideCaptureService {
}

Future<Uint8List> _imageToUint8List(ui.Image image) async {
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData!.buffer.asUint8List();
try {
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);

return byteData!.buffer.asUint8List();
} finally {
image.dispose();
}
}

/// Converts a Flutter widget to a [ui.Image] via an isolated render pipeline.
///
/// Sets up a complete render context (theme, media query, material app),
/// drives a bounded settle loop for async/delayed widgets, then rasterises.
/// Releases the temporary element, render, and focus resources on success,
/// on the settle limit, and on failure.
Future<ui.Image> _fromWidgetToImage(
Widget widget,
RenderConfig config,
) async {
RenderRepaintBoundary? repaintBoundary;
RenderPositionedBox? rootBox;
RenderView? renderView;
PipelineOwner? pipelineOwner;
FocusManager? focusManager;
BuildOwner? buildOwner;
RenderObjectToWidgetElement<RenderBox>? rootElement;

try {
final mixScope = MixScope.maybeOf(config.context);
final readiness = SlideCaptureReadiness();
Expand All @@ -242,7 +256,11 @@ class SlideCaptureService {
),
);

final repaintBoundary = RenderRepaintBoundary();
repaintBoundary = RenderRepaintBoundary();
rootBox = RenderPositionedBox(
alignment: Alignment.center,
child: repaintBoundary,
);
final platformDispatcher = WidgetsBinding.instance.platformDispatcher;

final view =
Expand All @@ -251,12 +269,9 @@ class SlideCaptureService {
config.targetSize ?? view.physicalSize / view.devicePixelRatio;
final physicalSize = logicalSize * config.pixelRatio;

final renderView = RenderView(
renderView = RenderView(
view: view,
child: RenderPositionedBox(
alignment: Alignment.center,
child: repaintBoundary,
),
child: rootBox,
configuration: ViewConfiguration(
logicalConstraints: BoxConstraints.tight(logicalSize),
physicalConstraints: BoxConstraints.tight(physicalSize),
Expand All @@ -265,18 +280,17 @@ class SlideCaptureService {
);

var isDirty = false;
final pipelineOwner = PipelineOwner(
onNeedVisualUpdate: () => isDirty = true,
);
final buildOwner = BuildOwner(
focusManager: FocusManager(),
pipelineOwner = PipelineOwner(onNeedVisualUpdate: () => isDirty = true);
focusManager = FocusManager();
buildOwner = BuildOwner(
focusManager: focusManager,
onBuildScheduled: () => isDirty = true,
);

pipelineOwner.rootNode = renderView;
renderView.prepareInitialFrame();

final rootElement = RenderObjectToWidgetAdapter<RenderBox>(
rootElement = RenderObjectToWidgetAdapter<RenderBox>(
container: repaintBoundary,
child: Directionality(textDirection: TextDirection.ltr, child: child),
).attachToRenderTree(buildOwner);
Expand Down Expand Up @@ -331,6 +345,61 @@ class SlideCaptureService {
} catch (e) {
log('Error finalizing tree: $e');
rethrow;
} finally {
_releaseCaptureTree(
buildOwner: buildOwner,
rootElement: rootElement,
repaintBoundary: repaintBoundary,
rootBox: rootBox,
renderView: renderView,
pipelineOwner: pipelineOwner,
focusManager: focusManager,
);
}
}

/// Unmounts the temporary capture subtree and releases its owned resources.
///
/// Rebuilding the root adapter without a child deactivates the captured
/// widgets, and [BuildOwner.finalizeTree] then unmounts them so their
/// [State.dispose] and render object disposal run. The render pipeline,
/// the render objects this service created, and the focus manager are
/// released afterwards.
void _releaseCaptureTree({
required BuildOwner? buildOwner,
required RenderObjectToWidgetElement<RenderBox>? rootElement,
required RenderRepaintBoundary? repaintBoundary,
required RenderPositionedBox? rootBox,
required RenderView? renderView,
required PipelineOwner? pipelineOwner,
required FocusManager? focusManager,
}) {
if (buildOwner != null && rootElement != null && repaintBoundary != null) {
try {
RenderObjectToWidgetAdapter<RenderBox>(
container: repaintBoundary,
).attachToRenderTree(buildOwner, rootElement);
buildOwner
..buildScope(rootElement)
..finalizeTree();
} catch (e, stackTrace) {
log('Error unmounting capture tree: $e', stackTrace: stackTrace);
}
}

if (pipelineOwner != null) {
pipelineOwner.rootNode = null;
pipelineOwner.dispose();
}

repaintBoundary?.dispose();
rootBox?.dispose();
renderView?.dispose();

if (focusManager != null) {
// Unmounting focus nodes schedules a focus update microtask. Disposing
// in a later microtask lets that update run against a live manager.
scheduleMicrotask(focusManager.dispose);
}
}
}
53 changes: 35 additions & 18 deletions packages/superdeck/lib/src/deck/deck_presentation_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ final class DeckPresentationState {
final _thumbnails = signal<Map<String, AsyncThumbnail>>({});

EffectCleanup? _indexClampEffect;
EffectCleanup? _thumbnailPruneEffect;
int _transitionOperation = 0;
bool _disposed = false;

late final GoRouter router = GoRouter(
Expand Down Expand Up @@ -57,6 +59,11 @@ final class DeckPresentationState {
_currentIndex.value = clamped;
}
});
// Thumbnail cleanup follows the slide collection, not thumbnail warmup,
// so obsolete handles are released even when the deck becomes empty.
_thumbnailPruneEffect = effect(() {
_pruneThumbnails(_slideKeys(_slides.value));
});
}

ReadonlySignal<bool> get isMenuOpen => _isMenuOpen;
Expand Down Expand Up @@ -96,10 +103,13 @@ final class DeckPresentationState {

Future<void> goToSlide(int index) async {
if (_disposed || index < 0 || index >= _slides.value.length) return;
// Only the latest transition may clear the transitioning state, so an
// earlier delay cannot end a transition that started after it.
final operation = ++_transitionOperation;
_isTransitioning.value = true;
router.go('/slides/$index');
await Future<void>.delayed(_transitionDuration);
if (_disposed) return;
if (_disposed || operation != _transitionOperation) return;
_isTransitioning.value = false;
}

Expand All @@ -121,28 +131,14 @@ final class DeckPresentationState {
bool force = false,
}) {
if (_disposed) return;
if (slides.isEmpty) return;

final validKeys = slides.map((s) => s.key).toSet();
final current = _thumbnails.value;
final staleKeys = current.keys
.where((k) => !validKeys.contains(k))
.toList(growable: false);
final cache = staleKeys.isEmpty
? current
: Map<String, AsyncThumbnail>.from(current);

for (final key in staleKeys) {
cache.remove(key)?.dispose();
}
if (staleKeys.isNotEmpty) {
_thumbnails.value = cache;
}
_pruneThumbnails(_slideKeys(slides));
if (slides.isEmpty) return;

_thumbnailService.generateThumbnails(
slides: slides,
context: context,
cache: cache,
cache: _thumbnails.peek(),
onCacheUpdate: (updated) {
if (_disposed) return;
_thumbnails.value = updated;
Expand Down Expand Up @@ -174,6 +170,7 @@ final class DeckPresentationState {
void dispose() {
_disposed = true;
_indexClampEffect?.call();
_thumbnailPruneEffect?.call();
router.routeInformationProvider.removeListener(_syncCurrentIndexFromRouter);
router.dispose();
for (final thumbnail in _thumbnails.value.values) {
Expand All @@ -190,6 +187,23 @@ final class DeckPresentationState {
currentSlide.dispose();
}

/// Disposes and drops every thumbnail whose slide is no longer present.
void _pruneThumbnails(Set<String> validKeys) {
if (_disposed) return;

final current = _thumbnails.peek();
final staleKeys = current.keys
.where((key) => !validKeys.contains(key))
.toList(growable: false);
if (staleKeys.isEmpty) return;

final cache = Map<String, AsyncThumbnail>.from(current);
for (final key in staleKeys) {
cache.remove(key)?.dispose();
}
_thumbnails.value = cache;
}

void _syncCurrentIndexFromRouter() {
if (_disposed) return;
final path = router.routeInformationProvider.value.uri.path;
Expand All @@ -205,6 +219,9 @@ final class DeckPresentationState {
}
}

static Set<String> _slideKeys(List<SlideConfiguration> slides) =>
slides.map((slide) => slide.key).toSet();

static int _clampIndex(int index, int totalSlides) {
final maxIndex = totalSlides > 0 ? totalSlides - 1 : 0;
return index.clamp(0, maxIndex);
Expand Down
Loading
Loading