Files
Garage-SDKs/garage_ui/lib/app_frame_capture.dart
T
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

116 lines
3.8 KiB
Dart

import "dart:ui" as ui;
import "package:flutter/foundation.dart";
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
// Wraps the whole app in a RepaintBoundary we can snapshot on demand, so an
// eyedropper has something to read pixels out of on platforms with no system
// colour sampler (see platform/eyedropper.dart for the ones that do).
//
// This obviously only covers whats inside the flutter window - thats the
// tradeoff for it working everywhere. Mounted once, in main.dart, just under
// CustomCursorLayer so the fake cursor doesnt end up baked into the snapshot
// and get sampled by the very thing thats drawing it.
class AppFrameCapture extends StatelessWidget {
const AppFrameCapture({super.key, required this.child});
static final GlobalKey _boundaryKey = GlobalKey(
debugLabel: "app frame capture",
);
final Widget child;
@override
Widget build(BuildContext context) {
return RepaintBoundary(key: _boundaryKey, child: child);
}
/// Grabs the current frame. Returns null (and logs why) if theres nothing
/// to grab - no boundary mounted yet, or the raster came back empty.
static Future<AppFrameSnapshot?> capture() async {
final object = _boundaryKey.currentContext?.findRenderObject();
if (object is! RenderRepaintBoundary) {
debugPrint(
"AppFrameCapture.capture: no boundary mounted, is AppFrameCapture in "
"the tree?",
);
return null;
}
ui.Image? image;
try {
// pixelRatio 1 on purpose - it keeps image pixels and logical pixels the
// same thing, so colorAt can index straight off a pointer position with
// no dpr maths, and it keeps the byte buffer a quarter of the size on a
// retina display.
image = await object.toImage();
final width = image.width;
final height = image.height;
final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (bytes == null) {
debugPrint("AppFrameCapture.capture: toByteData returned null");
return null;
}
return AppFrameSnapshot._(
bytes.buffer.asUint8List(),
width,
height,
object.localToGlobal(Offset.zero),
);
} catch (error, stack) {
debugPrint("AppFrameCapture.capture failed: $error\n$stack");
return null;
} finally {
image?.dispose();
}
}
}
/// A frozen copy of the app window you can read single pixels out of.
class AppFrameSnapshot {
AppFrameSnapshot._(this._pixels, this.width, this.height, this.origin);
/// straight-from-bytes ctor, only so the sampling maths can be probed
/// without standing up a whole render tree.
@visibleForTesting
AppFrameSnapshot.fromRawRgba({
required Uint8List pixels,
required this.width,
required this.height,
this.origin = Offset.zero,
}) : _pixels = pixels;
final Uint8List _pixels;
final int width;
final int height;
/// where the captured boundary sits in global coordinates. normally zero,
/// but not worth assuming.
final Offset origin;
/// The colour at [globalPosition], or null if thats outside the captured
/// area or lands on a fully transparent pixel.
Color? colorAt(Offset globalPosition) {
final local = globalPosition - origin;
final x = local.dx.floor();
final y = local.dy.floor();
if (x < 0 || y < 0 || x >= width || y >= height) return null;
final i = (y * width + x) * 4;
final a = _pixels[i + 3];
if (a == 0) return null;
// rawRgba is premultiplied, so anything drawn over a translucent layer
// reads darker than it looks unless we undo that first.
int channel(int offset) {
final value = _pixels[i + offset];
if (a == 255) return value;
return (value * 255 / a).round().clamp(0, 255);
}
return Color.fromARGB(255, channel(0), channel(1), channel(2));
}
}