Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
86b7e90
Draw image on Canvas - a working draft
FeodorFitsner Jul 11, 2025
720f056
New Canvas methods: `capture()`, `get_capture()`, `clear_capture()`
FeodorFitsner Jul 14, 2025
cb2424f
New `GestureDetector` events: `on_right_pan_start`, `on_right_pan_upd…
FeodorFitsner Jul 14, 2025
15af0f9
Added `PageMediaData.device_pixel_ratio` property
FeodorFitsner Jul 15, 2025
effb0c2
Canvas.capture() fixed for DPR
FeodorFitsner Jul 17, 2025
f482769
BaseControl. _trigger_event, context moved to a separate module
FeodorFitsner Jul 17, 2025
236c4fe
Scale canvas to account for DPR when taking capture
FeodorFitsner Jul 18, 2025
5d55afb
Preserve capture size
FeodorFitsner Jul 20, 2025
e4837dd
Fix: page.page should return Page.
FeodorFitsner Jul 20, 2025
d9e7e8b
KeyboardListener control
FeodorFitsner Jul 21, 2025
1b63fa7
Allow FilePicker.save_file on web
FeodorFitsner Jul 21, 2025
7748440
Merge branch 'main' into feodor/v1-canvas-recording
FeodorFitsner Aug 2, 2025
be9d3c4
Merge branch 'main' into feodor/v1-canvas-recording
FeodorFitsner Aug 5, 2025
1041f48
Update right-pan event
FeodorFitsner Aug 5, 2025
b18b3d3
All canvas shapes can be dashed
FeodorFitsner Aug 5, 2025
3642e02
Renamed example
FeodorFitsner Aug 6, 2025
8663e3b
Add KeyboardListener docs and improve canvas docstrings
FeodorFitsner Aug 6, 2025
8220e36
Add keyboard listener example and improve docs
FeodorFitsner Aug 6, 2025
07dc1fd
Enhance PageView with detailed docstrings and base class
FeodorFitsner Aug 6, 2025
2f0ed30
Fix tests: Remove ScrollableControl from PageView inheritance
FeodorFitsner Aug 6, 2025
643fdff
Merge branch 'main' into feodor/v1-canvas-recording
FeodorFitsner Aug 6, 2025
922012e
Refactor integration tests and add canvas test coverage
FeodorFitsner Aug 7, 2025
f4b9908
Lower pytest log level to INFO in AppVeyor config
FeodorFitsner Aug 7, 2025
d018c39
Update CI config and test finders only
FeodorFitsner Aug 7, 2025
78e5372
Run all integration tests
FeodorFitsner Aug 7, 2025
e2e0655
Update test runner and increase test delay in test_canvas.py
FeodorFitsner Aug 7, 2025
3778399
Run all tests, again
FeodorFitsner Aug 7, 2025
c0775e6
Refactor screenshot test timing in canvas tests
FeodorFitsner Aug 7, 2025
a4838fb
Add canvas capture tests and enable full CI matrix
FeodorFitsner Aug 8, 2025
d761dce
Refactor control initialization and property handling
FeodorFitsner Aug 8, 2025
06ad8ff
Add pixel_ratio support to Canvas capture methods
FeodorFitsner Aug 8, 2025
40a15e6
Apply suggestion from @Copilot
FeodorFitsner Aug 8, 2025
dfa7299
Update __init__.py
FeodorFitsner Aug 9, 2025
c36c5f4
improve some docstrings
ndonkoHenri Aug 9, 2025
035ad3d
`on_media_change` event receives new media data
ndonkoHenri Aug 9, 2025
03fb5cb
Canvas example: Add image saving and optimize canvas capture
FeodorFitsner Aug 9, 2025
083ce4e
Refactor canvas shape constructors to use keyword args
FeodorFitsner Aug 9, 2025
7eb6bb4
Add platform checks and validation to FilePicker methods
FeodorFitsner Aug 9, 2025
36d9da4
Remove base64 decoding fallback in loadCanvasImage
FeodorFitsner Aug 9, 2025
0b14355
Merge branch 'feodor/v1-canvas-recording' of https://github.com/flet-…
FeodorFitsner Aug 9, 2025
e67d21b
use FletExceptions and remove outdated brush.gif
ndonkoHenri Aug 9, 2025
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
4 changes: 2 additions & 2 deletions client/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:flet_audio/flet_audio.dart' as flet_audio;
// --FAT_CLIENT_END--
import 'package:flet_audio_recorder/flet_audio_recorder.dart'
as flet_audio_recorder;
import 'package:flet_charts/flet_charts.dart' as flet_charts;
import 'package:flet_datatable2/flet_datatable2.dart' as flet_datatable2;
import "package:flet_flashlight/flet_flashlight.dart" as flet_flashlight;
import 'package:flet_geolocator/flet_geolocator.dart' as flet_geolocator;
Expand All @@ -19,7 +20,6 @@ import 'package:flet_rive/flet_rive.dart' as flet_rive;
import 'package:flet_video/flet_video.dart' as flet_video;
// --FAT_CLIENT_END--
import 'package:flet_webview/flet_webview.dart' as flet_webview;
import 'package:flet_charts/flet_charts.dart' as flet_charts;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
Expand Down Expand Up @@ -64,7 +64,7 @@ void main([List<String>? args]) async {
//debugPrint("Uri.base: ${Uri.base}");

if (kDebugMode) {
pageUrl = "tcp://localhost:8550";
pageUrl = "http://localhost:8550";
}

if (kIsWeb) {
Expand Down
2 changes: 1 addition & 1 deletion packages/flet/lib/src/controls/base_controls.dart
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ Widget _sizedControl(Widget widget, Control control) {
var width = control.getDouble("width");
var height = control.getDouble("height");
if ((width != null || height != null) &&
!["container", "image"].contains(control.type)) {
!["Container", "Image"].contains(control.type)) {
widget = ConstrainedBox(
constraints: BoxConstraints.tightFor(width: width, height: height),
child: widget,
Expand Down
213 changes: 206 additions & 7 deletions packages/flet/lib/src/controls/canvas.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flet/src/extensions/control.dart';
Expand All @@ -7,9 +11,11 @@ import 'package:flet/src/utils/colors.dart';
import 'package:flet/src/utils/drawing.dart';
import 'package:flet/src/utils/numbers.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

import '../models/control.dart';
import '../utils/dash_path.dart';
import '../utils/hashing.dart';
import '../utils/images.dart';
import '../utils/text.dart';
import '../utils/transforms.dart';
Expand All @@ -30,6 +36,93 @@ class CanvasControl extends StatefulWidget {
class _CanvasControlState extends State<CanvasControl> {
int _lastResize = DateTime.now().millisecondsSinceEpoch;
Size? _lastSize;
ui.Image? _capturedImage;
Size? _capturedSize;
@override
void initState() {
super.initState();
widget.control.addInvokeMethodListener(_invokeMethod);
}

@override
void dispose() {
widget.control.removeInvokeMethodListener(_invokeMethod);
super.dispose();
}

Future<void> _awaitImageLoads(List<Control> shapes) async {
final pending = <Future>[];

for (final shape in shapes) {
if (shape.type == "Image") {
if (shape.get("_loading") != null) {
pending.add(shape.get("_loading").future);
} else if (shape.get("_image") == null ||
shape.get("_hash") != getImageHash(shape)) {
final future = loadCanvasImage(shape);
pending.add(future);
}
}
}

if (pending.isNotEmpty) {
await Future.wait(pending);
}
}

Future<dynamic> _invokeMethod(String name, dynamic args) async {
debugPrint("Canvas.$name($args)");
switch (name) {
case "capture":
final shapes = widget.control.children("shapes");
final logicalSize = _lastSize;
if (logicalSize == null || logicalSize.isEmpty || shapes.isEmpty) {
return;
}

// Wait for all images to load
await _awaitImageLoads(shapes);

if (!mounted) return;

final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);

final painter = FletCustomPainter(
context: context,
theme: Theme.of(context),
shapes: shapes,
onPaintCallback: (_) {},
);

painter.paint(canvas, logicalSize);

final picture = recorder.endRecording();
_capturedImage = await picture.toImage(
logicalSize.width.ceil(),
logicalSize.height.ceil(),
);
_capturedSize = logicalSize;
setState(() {}); // trigger rebuild
return;

case "get_capture":
if (_capturedImage == null) return null;
final byteData =
await _capturedImage!.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();

case "clear_capture":
_capturedImage?.dispose();
_capturedImage = null;
_capturedSize = null;
setState(() {});
return;

default:
throw Exception("Unknown Canvas method: $name");
}
}

@override
Widget build(BuildContext context) {
Expand All @@ -43,13 +136,15 @@ class _CanvasControlState extends State<CanvasControl> {
context: context,
theme: Theme.of(context),
shapes: widget.control.children("shapes"),
capturedImage: _capturedImage,
capturedSize: _capturedSize,
onPaintCallback: (size) {
_lastSize = size;
if (onResize) {
var now = DateTime.now().millisecondsSinceEpoch;
if ((now - _lastResize > resizeInterval && _lastSize != size) ||
_lastSize == null) {
_lastResize = now;
_lastSize = size;
widget.control
.triggerEvent("resize", {"w": size.width, "h": size.height});
}
Expand All @@ -68,19 +163,35 @@ class FletCustomPainter extends CustomPainter {
final ThemeData theme;
final List<Control> shapes;
final CanvasControlOnPaintCallback onPaintCallback;

const FletCustomPainter(
{required this.context,
required this.theme,
required this.shapes,
required this.onPaintCallback});
final ui.Image? capturedImage;
final Size? capturedSize;

const FletCustomPainter({
required this.context,
required this.theme,
required this.shapes,
required this.onPaintCallback,
this.capturedImage,
this.capturedSize,
});

@override
void paint(Canvas canvas, Size size) {
onPaintCallback(size);

//debugPrint("SHAPE CONTROLS: $shapes");

canvas.save();
canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height));

if (capturedImage != null && capturedSize != null) {
final src = Rect.fromLTWH(0, 0, capturedImage!.width.toDouble(),
capturedImage!.height.toDouble());
final dst =
Rect.fromLTWH(0, 0, capturedSize!.width, capturedSize!.height);
canvas.drawImageRect(capturedImage!, src, dst, Paint());
}

for (var shape in shapes) {
shape.notifyParent = true;
if (shape.type == "Line") {
Expand All @@ -105,8 +216,12 @@ class FletCustomPainter extends CustomPainter {
drawShadow(canvas, shape);
} else if (shape.type == "Text") {
drawText(context, canvas, shape);
} else if (shape.type == "Image") {
drawImage(canvas, shape);
}
}

canvas.restore();
}

@override
Expand Down Expand Up @@ -260,6 +375,28 @@ class FletCustomPainter extends CustomPainter {
canvas.drawShadow(path, color, elevation, transparentOccluder);
}

void drawImage(Canvas canvas, Control shape) {
final paint = shape.getPaint("paint", theme, Paint())!;
final x = shape.getDouble("x")!;
final y = shape.getDouble("y")!;
final width = shape.getDouble("width");
final height = shape.getDouble("height");

// Check if image is already loaded and stored
if (shape.get("_image") != null &&
shape.get("_hash") == getImageHash(shape)) {
final img = shape.get("_image")!;
final srcRect =
Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
final dstRect = width != null && height != null
? Rect.fromLTWH(x, y, width, height)
: Offset(x, y) & Size(img.width.toDouble(), img.height.toDouble());
canvas.drawImageRect(img, srcRect, dstRect, paint);
} else {
loadCanvasImage(shape);
}
}

ui.Path buildPath(dynamic j) {
var path = ui.Path();
if (j == null) {
Expand Down Expand Up @@ -330,3 +467,65 @@ class FletCustomPainter extends CustomPainter {
return path;
}
}

Future<void> loadCanvasImage(Control shape) async {
debugPrint("loadCanvasImage(${shape.id})");
if (shape.get("_loading") != null) return;
final completer = Completer();
shape.properties["_loading"] = completer;

final src = shape.getString("src");
final srcBytes = shape.get("src_bytes") as Uint8List?;
final width = shape.getInt("width");
final height = shape.getInt("height");

try {
Uint8List bytes;

if (srcBytes != null) {
bytes = srcBytes;
} else if (src != null) {
var assetSrc = shape.backend.getAssetSource(src);
if (assetSrc.isFile) {
final file = File(assetSrc.path);
bytes = await file.readAsBytes();
} else {
final resp = await http.get(Uri.parse(assetSrc.path));
if (resp.statusCode != 200) {
throw Exception("HTTP ${resp.statusCode}");
}
bytes = resp.bodyBytes;
}
} else if (src != null) {
bytes = base64Decode(src);
} else {
throw Exception("Missing image source: 'src' or 'src_bytes'");
}

final codec = await ui.instantiateImageCodec(
bytes,
targetWidth: width,
targetHeight: height,
);
final frame = await codec.getNextFrame();
shape.properties["_image"] = frame.image;
shape.updateProperties({"_hash": getImageHash(shape)},
python: false, notify: true);
completer.complete();
} catch (e) {
shape.properties["_image_error"] = e;
completer.completeError(e);
} finally {
shape.properties.remove("_loading");
}
}

int getImageHash(Control shape) {
final src = shape.getString("src");
final srcBytes = shape.get("src_bytes") as Uint8List?;
return src != null
? src.hashCode
: srcBytes != null
? fnv1aHash(srcBytes)
: 0;
}
63 changes: 54 additions & 9 deletions packages/flet/lib/src/controls/gesture_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class _GestureDetectorControlState extends State<GestureDetectorControl> {
double _hoverX = 0;
double _hoverY = 0;
Timer? _debounce;
bool _rightPanActive = false;
int _rightPanTimestamp = DateTime.now().millisecondsSinceEpoch;
double _rightPanStartX = 0.0;
double _rightPanStartY = 0.0;

@override
void initState() {
Expand Down Expand Up @@ -365,16 +369,57 @@ class _GestureDetectorControlState extends State<GestureDetectorControl> {
)
: result;

result = onScroll
? Listener(
behavior: HitTestBehavior.translucent,
onPointerSignal: (details) {
if (details is PointerScrollEvent) {
widget.control.triggerEvent("scroll", details.toMap());
var onRightPanStart = widget.control.getBool("on_right_pan_start", false)!;
var onRightPanUpdate =
widget.control.getBool("on_right_pan_update", false)!;
var onRightPanEnd = widget.control.getBool("on_right_pan_end", false)!;

if (onScroll || onRightPanStart || onRightPanUpdate || onRightPanEnd) {
result = Listener(
behavior: HitTestBehavior.translucent,
onPointerSignal: onScroll
? (details) {
if (details is PointerScrollEvent) {
widget.control.triggerEvent("scroll", details.toMap());
}
}
},
child: result)
: result;
: null,
onPointerDown: onRightPanStart
? (event) {
if (event.kind == PointerDeviceKind.mouse &&
event.buttons == kSecondaryMouseButton) {
_rightPanActive = true;
_rightPanStartX = event.localPosition.dx;
_rightPanStartY = event.localPosition.dy;
widget.control.triggerEvent("right_pan_start", event.toMap());
}
}
: null,
onPointerMove: onRightPanUpdate
? (event) {
if (_rightPanActive && event.buttons == kSecondaryMouseButton) {
var now = DateTime.now().millisecondsSinceEpoch;
if (now - _rightPanTimestamp > dragInterval) {
_rightPanTimestamp = now;
widget.control.triggerEvent("right_pan_update",
event.toMap(_rightPanStartX, _rightPanStartY));
_rightPanStartX = event.localPosition.dx;
_rightPanStartY = event.localPosition.dy;
}
}
}
: null,
onPointerUp: onRightPanEnd
? (event) {
if (_rightPanActive) {
_rightPanActive = false;
widget.control.triggerEvent("right_pan_end", event.toMap());
}
}
: null,
child: result,
);
}

var mouseCursor =
parseMouseCursor(widget.control.getString("mouse_cursor"));
Expand Down
Loading