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 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)); } }