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
116 lines
3.8 KiB
Dart
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));
|
|
}
|
|
}
|