From 86b7e90f7a986ae644484d6d2215f35cce980f20 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 11 Jul 2025 13:41:12 -0700 Subject: [PATCH 01/11] Draw image on Canvas - a working draft --- client/lib/main.dart | 4 +- packages/flet/lib/src/controls/canvas.dart | 89 +++++++++++++++++++ packages/flet/lib/src/controls/image.dart | 1 - packages/flet/lib/src/controls/markdown.dart | 4 - packages/flet/lib/src/utils/box.dart | 5 +- packages/flet/lib/src/utils/hashing.dart | 13 +++ .../packages/flet/src/flet/canvas/__init__.py | 2 + .../src/flet/controls/core/canvas/image.py | 52 +++++++++++ .../flet/src/flet/controls/core/image.py | 3 +- 9 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 packages/flet/lib/src/utils/hashing.dart create mode 100644 sdk/python/packages/flet/src/flet/controls/core/canvas/image.py diff --git a/client/lib/main.dart b/client/lib/main.dart index c39536bd4..8fa2f62b9 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -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; @@ -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'; @@ -64,7 +64,7 @@ void main([List? args]) async { //debugPrint("Uri.base: ${Uri.base}"); if (kDebugMode) { - pageUrl = "tcp://localhost:8550"; + pageUrl = "http://localhost:8550"; } if (kIsWeb) { diff --git a/packages/flet/lib/src/controls/canvas.dart b/packages/flet/lib/src/controls/canvas.dart index 5ce085f0b..d6eee91e7 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -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'; @@ -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'; @@ -105,6 +111,8 @@ class FletCustomPainter extends CustomPainter { drawShadow(canvas, shape); } else if (shape.type == "Text") { drawText(context, canvas, shape); + } else if (shape.type == "Image") { + drawImage(canvas, shape); } } } @@ -260,6 +268,87 @@ class FletCustomPainter extends CustomPainter { canvas.drawShadow(path, color, elevation, transparentOccluder); } + Future loadCanvasImage(Control shape) async { + debugPrint("loadCanvasImage(${shape.id})"); + if (shape.get("_loading") == true) return; + shape.properties["_loading"] = true; + + 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); + } catch (e) { + shape.properties["_image_error"] = 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; + } + + 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) { diff --git a/packages/flet/lib/src/controls/image.dart b/packages/flet/lib/src/controls/image.dart index 0593903ad..5cb2a984e 100644 --- a/packages/flet/lib/src/controls/image.dart +++ b/packages/flet/lib/src/controls/image.dart @@ -37,7 +37,6 @@ class ImageControl extends StatelessWidget { Widget? image = buildImage( context: context, - control: control, src: src, srcBase64: srcBase64, srcBytes: srcBytes, diff --git a/packages/flet/lib/src/controls/markdown.dart b/packages/flet/lib/src/controls/markdown.dart index 2a0e25f88..fdf7922af 100644 --- a/packages/flet/lib/src/controls/markdown.dart +++ b/packages/flet/lib/src/controls/markdown.dart @@ -1,5 +1,3 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:markdown/markdown.dart' as md; @@ -66,10 +64,8 @@ class MarkdownControl extends StatelessWidget { return buildImage( context: context, - control: control, src: src, srcBase64: srcBase64, - srcBytes: Uint8List(0), semanticsLabel: alt, disabled: control.disabled, errorCtrl: control.buildWidget("img_error_content")); diff --git a/packages/flet/lib/src/utils/box.dart b/packages/flet/lib/src/utils/box.dart index f632bc645..bcb2b04e4 100644 --- a/packages/flet/lib/src/utils/box.dart +++ b/packages/flet/lib/src/utils/box.dart @@ -170,11 +170,10 @@ ImageProvider? getImageProvider( Widget buildImage({ required BuildContext context, - required Control control, required Widget? errorCtrl, required String? src, required String? srcBase64, - required Uint8List srcBytes, + Uint8List? srcBytes, double? width, double? height, ImageRepeat repeat = ImageRepeat.noRepeat, @@ -193,7 +192,7 @@ Widget buildImage({ Widget? image; const String svgTag = " xmlns=\"http://www.w3.org/2000/svg\""; - Uint8List bytes = srcBytes; + Uint8List bytes = srcBytes ?? Uint8List(0); if (bytes.isEmpty && srcBase64 != null && srcBase64.isNotEmpty) { bytes = base64Decode(srcBase64); } diff --git a/packages/flet/lib/src/utils/hashing.dart b/packages/flet/lib/src/utils/hashing.dart new file mode 100644 index 000000000..f9d5423c2 --- /dev/null +++ b/packages/flet/lib/src/utils/hashing.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +int fnv1aHash(Uint8List bytes) { + const int fnvOffset = 0x811C9DC5; + const int fnvPrime = 0x01000193; + + int hash = fnvOffset; + for (final byte in bytes) { + hash ^= byte; + hash = (hash * fnvPrime) & 0xFFFFFFFF; // 32-bit overflow + } + return hash; +} diff --git a/sdk/python/packages/flet/src/flet/canvas/__init__.py b/sdk/python/packages/flet/src/flet/canvas/__init__.py index 7cea3ddce..fdceb5093 100644 --- a/sdk/python/packages/flet/src/flet/canvas/__init__.py +++ b/sdk/python/packages/flet/src/flet/canvas/__init__.py @@ -3,6 +3,7 @@ from flet.controls.core.canvas.circle import Circle from flet.controls.core.canvas.color import Color from flet.controls.core.canvas.fill import Fill +from flet.controls.core.canvas.image import Image from flet.controls.core.canvas.line import Line from flet.controls.core.canvas.oval import Oval from flet.controls.core.canvas.path import Path @@ -26,4 +27,5 @@ "Rect", "Shadow", "Text", + "Image", ] diff --git a/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py b/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py new file mode 100644 index 000000000..2bbe3f105 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py @@ -0,0 +1,52 @@ +from typing import Optional + +from flet.controls.base_control import control +from flet.controls.core.canvas.shape import Shape +from flet.controls.painting import Paint +from flet.controls.types import OptionalNumber, OptionalString + + +@control("Image") +class Image(Shape): + """ + Draws an image. + """ + + src: OptionalString = None + """ + Draws an image from a source. + + This could be an external URL or a local + [asset file](https://flet.dev/docs/cookbook/assets). + """ + + src_bytes: Optional[bytes] = None + """ + Draws an image from a bytes array. + """ + + x: OptionalNumber = None + """ + The x-axis coordinate of the image's top-left corner. + """ + + y: OptionalNumber = None + """ + The y-axis coordinate of the image's top-left corner. + """ + + width: OptionalNumber = None + """ + The width of the rectangle to draw the image into. Use image width if None. + """ + + height: OptionalNumber = None + """ + The height of the rectangle to draw the image into. Use image height if None. + """ + + paint: Optional[Paint] = None + """ + A paint to composite the image into canvas. The value of this property + is the instance of [`Paint`](https://flet.dev/docs/reference/types/paint) class. + """ diff --git a/sdk/python/packages/flet/src/flet/controls/core/image.py b/sdk/python/packages/flet/src/flet/controls/core/image.py index ccc07ebec..1efab677a 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/image.py +++ b/sdk/python/packages/flet/src/flet/controls/core/image.py @@ -54,7 +54,7 @@ class Image(ConstrainedControl): src_bytes: Optional[bytes] = None """ - TBD + Displays an image from a bytes array. """ error_content: OptionalControl = None @@ -159,4 +159,3 @@ class Image(ConstrainedControl): Anti-aliasing alleviates the sawtooth artifact when the image is rotated. """ - From 720f05601dd8c5894251060850f50bf49cc5c745 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Mon, 14 Jul 2025 13:18:25 -0700 Subject: [PATCH 02/11] New Canvas methods: `capture()`, `get_capture()`, `clear_capture()` Other canvas changes: - canvas drawing is clipped - canvas size is stretched to maximum if `expand` is set --- .../flet/lib/src/controls/base_controls.dart | 2 +- packages/flet/lib/src/controls/canvas.dart | 242 +++++++++++++----- .../src/flet/controls/core/canvas/canvas.py | 24 ++ 3 files changed, 201 insertions(+), 67 deletions(-) diff --git a/packages/flet/lib/src/controls/base_controls.dart b/packages/flet/lib/src/controls/base_controls.dart index 78f79cc7d..985434965 100644 --- a/packages/flet/lib/src/controls/base_controls.dart +++ b/packages/flet/lib/src/controls/base_controls.dart @@ -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, diff --git a/packages/flet/lib/src/controls/canvas.dart b/packages/flet/lib/src/controls/canvas.dart index d6eee91e7..731723eef 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -36,6 +36,93 @@ class CanvasControl extends StatefulWidget { class _CanvasControlState extends State { 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 _awaitImageLoads(List shapes) async { + final pending = []; + + 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 _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) { @@ -49,13 +136,15 @@ class _CanvasControlState extends State { 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}); } @@ -74,12 +163,17 @@ class FletCustomPainter extends CustomPainter { final ThemeData theme; final List 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) { @@ -87,6 +181,17 @@ class FletCustomPainter extends CustomPainter { //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") { @@ -115,6 +220,8 @@ class FletCustomPainter extends CustomPainter { drawImage(canvas, shape); } } + + canvas.restore(); } @override @@ -268,65 +375,6 @@ class FletCustomPainter extends CustomPainter { canvas.drawShadow(path, color, elevation, transparentOccluder); } - Future loadCanvasImage(Control shape) async { - debugPrint("loadCanvasImage(${shape.id})"); - if (shape.get("_loading") == true) return; - shape.properties["_loading"] = true; - - 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); - } catch (e) { - shape.properties["_image_error"] = 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; - } - void drawImage(Canvas canvas, Control shape) { final paint = shape.getPaint("paint", theme, Paint())!; final x = shape.getDouble("x")!; @@ -419,3 +467,65 @@ class FletCustomPainter extends CustomPainter { return path; } } + +Future 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; +} diff --git a/sdk/python/packages/flet/src/flet/controls/core/canvas/canvas.py b/sdk/python/packages/flet/src/flet/controls/core/canvas/canvas.py index 5f6b1054c..750ce8f1e 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/canvas/canvas.py +++ b/sdk/python/packages/flet/src/flet/controls/core/canvas/canvas.py @@ -1,3 +1,4 @@ +import asyncio from dataclasses import dataclass, field from typing import Optional @@ -53,3 +54,26 @@ class Canvas(ConstrainedControl): Event object `e` is an instance of [CanvasResizeEvent](https://flet.dev/docs/reference/types/canvasresizeevent). """ + + def before_update(self): + super().before_update() + if self.expand: + if self.width is None: + self.width = float("inf") + if self.height is None: + self.height = float("inf") + + async def capture_async(self): + await self._invoke_method_async("capture") + + def capture(self): + asyncio.create_task(self.capture_async()) + + async def get_capture_async(self): + return await self._invoke_method_async("get_capture") + + async def clear_capture_async(self): + await self._invoke_method_async("capture") + + def clear_capture(self): + asyncio.create_task(self.clear_capture_async()) From cb2424f2ce485c6d4f3d28bccf13981812448a7b Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Mon, 14 Jul 2025 15:43:04 -0700 Subject: [PATCH 03/11] New `GestureDetector` events: `on_right_pan_start`, `on_right_pan_update`, `on_right_pan_end` --- .../lib/src/controls/gesture_detector.dart | 63 ++++++++++++++++--- .../flet_backend_channel_javascript_web.dart | 7 ++- .../flet_backend_channel_web_socket.dart | 4 +- .../flet/controls/core/gesture_detector.py | 28 +++++++++ 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/flet/lib/src/controls/gesture_detector.dart b/packages/flet/lib/src/controls/gesture_detector.dart index 78ee26226..4b2f1a5f3 100644 --- a/packages/flet/lib/src/controls/gesture_detector.dart +++ b/packages/flet/lib/src/controls/gesture_detector.dart @@ -37,6 +37,10 @@ class _GestureDetectorControlState extends State { 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() { @@ -365,16 +369,57 @@ class _GestureDetectorControlState extends State { ) : 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")); diff --git a/packages/flet/lib/src/transport/flet_backend_channel_javascript_web.dart b/packages/flet/lib/src/transport/flet_backend_channel_javascript_web.dart index 93ea9ed54..08ce02eb4 100644 --- a/packages/flet/lib/src/transport/flet_backend_channel_javascript_web.dart +++ b/packages/flet/lib/src/transport/flet_backend_channel_javascript_web.dart @@ -5,6 +5,7 @@ import 'package:msgpack_dart/msgpack_dart.dart' as msgpack; import '../protocol/message.dart'; import 'flet_backend_channel.dart'; +import 'flet_msgpack_encoder.dart'; @JS() external JSPromise jsConnect( @@ -49,7 +50,11 @@ class FletJavaScriptBackendChannel implements FletBackendChannel { @override void send(Message message) { - jsSend(address, msgpack.serialize(message.toList()).toJS); + jsSend( + address, + msgpack + .serialize(message.toList(), extEncoder: FletMsgpackEncoder()) + .toJS); } @override diff --git a/packages/flet/lib/src/transport/flet_backend_channel_web_socket.dart b/packages/flet/lib/src/transport/flet_backend_channel_web_socket.dart index 908ffe03c..6a268e403 100644 --- a/packages/flet/lib/src/transport/flet_backend_channel_web_socket.dart +++ b/packages/flet/lib/src/transport/flet_backend_channel_web_socket.dart @@ -8,6 +8,7 @@ import '../utils/platform_utils_web.dart' if (dart.library.io) "../utils/platform_utils_non_web.dart"; import '../utils/uri.dart'; import 'flet_backend_channel.dart'; +import 'flet_msgpack_encoder.dart'; class FletWebSocketBackendChannel implements FletBackendChannel { late final String _wsUrl; @@ -60,7 +61,8 @@ class FletWebSocketBackendChannel implements FletBackendChannel { @override void send(Message message) { - _channel?.sink.add(msgpack.serialize(message.toList())); + _channel?.sink.add( + msgpack.serialize(message.toList(), extEncoder: FletMsgpackEncoder())); } @override diff --git a/sdk/python/packages/flet/src/flet/controls/core/gesture_detector.py b/sdk/python/packages/flet/src/flet/controls/core/gesture_detector.py index fc22e624b..522bf713f 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/gesture_detector.py +++ b/sdk/python/packages/flet/src/flet/controls/core/gesture_detector.py @@ -12,6 +12,7 @@ HoverEvent, LongPressEndEvent, LongPressStartEvent, + PointerEvent, ScaleEndEvent, ScaleStartEvent, ScaleUpdateEvent, @@ -294,6 +295,33 @@ class GestureDetector(ConstrainedControl, AdaptiveControl): [`DragEndEvent`](https://flet.dev/docs/reference/types/dragendevent). """ + on_right_pan_start: OptionalEventHandler[PointerEvent["GestureDetector"]] = None + """ + Pointer has contacted the screen while secondary button pressed + and has begun to move. + + Event handler argument is of type + [`PointerEvent`](https://flet.dev/docs/reference/types/PointerEvent). + """ + + on_right_pan_update: OptionalEventHandler[PointerEvent["GestureDetector"]] = None + """ + A pointer that is in contact with the screen, secondary button pressed + and moving has moved again. + + Event handler argument is of type + [`PointerEvent`](https://flet.dev/docs/reference/types/PointerEvent). + """ + + on_right_pan_end: OptionalEventHandler[PointerEvent["GestureDetector"]] = None + """ + A pointer with secondary button pressed is no longer in contact + and was moving at a specific velocity. + + Event handler argument is of type + [`PointerEvent`](https://flet.dev/docs/reference/types/PointerEvent). + """ + on_scale_start: OptionalEventHandler[ScaleStartEvent["GestureDetector"]] = None """ The pointers in contact with the screen have established a focal point and initial From 15af0f9fbf82f8b5633921a74dc464404aa03060 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Mon, 14 Jul 2025 18:02:17 -0700 Subject: [PATCH 04/11] Added `PageMediaData.device_pixel_ratio` property --- packages/flet/lib/src/flet_backend.dart | 3 ++- packages/flet/lib/src/protocol/page_media_data.dart | 8 ++++++-- packages/flet/lib/src/widgets/page_media.dart | 10 ++++------ .../packages/flet/src/flet/controls/page_view.py | 10 +++++++++- sdk/python/packages/flet/tests/test_from_dict.py | 1 + 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/flet/lib/src/flet_backend.dart b/packages/flet/lib/src/flet_backend.dart index c5d769479..ebee6e8af 100644 --- a/packages/flet/lib/src/flet_backend.dart +++ b/packages/flet/lib/src/flet_backend.dart @@ -73,7 +73,8 @@ class FletBackend extends ChangeNotifier { PageMediaData media = PageMediaData( padding: PaddingData(EdgeInsets.zero), viewPadding: PaddingData(EdgeInsets.zero), - viewInsets: PaddingData(EdgeInsets.zero)); + viewInsets: PaddingData(EdgeInsets.zero), + devicePixelRatio: 0); TargetPlatform platform = defaultTargetPlatform; late Control _page; diff --git a/packages/flet/lib/src/protocol/page_media_data.dart b/packages/flet/lib/src/protocol/page_media_data.dart index 49088e24b..69ea70324 100644 --- a/packages/flet/lib/src/protocol/page_media_data.dart +++ b/packages/flet/lib/src/protocol/page_media_data.dart @@ -5,20 +5,24 @@ class PageMediaData extends Equatable { final PaddingData padding; final PaddingData viewPadding; final PaddingData viewInsets; + final double devicePixelRatio; const PageMediaData( {required this.padding, required this.viewPadding, - required this.viewInsets}); + required this.viewInsets, + required this.devicePixelRatio}); Map toMap() => { 'padding': padding.toMap(), 'view_padding': viewPadding.toMap(), 'view_insets': viewInsets.toMap(), + 'device_pixel_ratio': devicePixelRatio }; @override - List get props => [padding, viewPadding, viewInsets]; + List get props => + [padding, viewPadding, viewInsets, devicePixelRatio]; } class PaddingData extends Equatable { diff --git a/packages/flet/lib/src/widgets/page_media.dart b/packages/flet/lib/src/widgets/page_media.dart index ca2dcec28..89dab74b8 100644 --- a/packages/flet/lib/src/widgets/page_media.dart +++ b/packages/flet/lib/src/widgets/page_media.dart @@ -52,13 +52,11 @@ class _PageMediaState extends State { _onPlatformBrightnessChanged(platformBrightness); } - var padding = MediaQuery.paddingOf(context); - var viewPadding = MediaQuery.viewPaddingOf(context); - var viewInsets = MediaQuery.viewInsetsOf(context); var newMedia = PageMediaData( - padding: PaddingData(padding), - viewPadding: PaddingData(viewPadding), - viewInsets: PaddingData(viewInsets)); + padding: PaddingData(MediaQuery.paddingOf(context)), + viewPadding: PaddingData(MediaQuery.viewPaddingOf(context)), + viewInsets: PaddingData(MediaQuery.viewInsetsOf(context)), + devicePixelRatio: MediaQuery.devicePixelRatioOf(context)); if (newMedia != backend.media || !pageSizeUpdated) { _onMediaChanged(newMedia); diff --git a/sdk/python/packages/flet/src/flet/controls/page_view.py b/sdk/python/packages/flet/src/flet/controls/page_view.py index caa467297..2163334d8 100644 --- a/sdk/python/packages/flet/src/flet/controls/page_view.py +++ b/sdk/python/packages/flet/src/flet/controls/page_view.py @@ -50,6 +50,7 @@ class PageMediaData: padding: Padding view_padding: Padding view_insets: Padding + device_pixel_ratio: float @dataclass @@ -100,7 +101,14 @@ class PageView(AdaptiveControl): width: OptionalNumber = None height: OptionalNumber = None title: Optional[str] = None - media: Optional[PageMediaData] = None + media: PageMediaData = field( + default_factory=lambda: PageMediaData( + padding=Padding.zero(), + view_padding=Padding.zero(), + view_insets=Padding.zero(), + device_pixel_ratio=0, + ) + ) scroll_event_interval: OptionalNumber = None on_resize: OptionalEventHandler["PageResizeEvent"] = None """ diff --git a/sdk/python/packages/flet/tests/test_from_dict.py b/sdk/python/packages/flet/tests/test_from_dict.py index 45b7e3d7c..49a55f4ed 100644 --- a/sdk/python/packages/flet/tests/test_from_dict.py +++ b/sdk/python/packages/flet/tests/test_from_dict.py @@ -14,6 +14,7 @@ def test_page_media_data(): "padding": {"left": 1, "top": 2, "right": 3, "bottom": 4}, "view_padding": {"left": 1, "top": 2, "right": 3, "bottom": 4}, "view_insets": {"left": 1, "top": 2, "right": 3, "bottom": 4}, + "device_pixel_ratio": 1, }, ) From effb0c2a0c4d5f973803f2929c9159c42a956978 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 16 Jul 2025 18:34:40 -0700 Subject: [PATCH 05/11] Canvas.capture() fixed for DPR --- .gitignore | 1 + packages/flet/lib/src/controls/canvas.dart | 81 ++++++++++++---------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 6712a75ab..4a09f882e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ .python-version vendor/ /client/android/app/.cxx +client/devtools_options.yaml diff --git a/packages/flet/lib/src/controls/canvas.dart b/packages/flet/lib/src/controls/canvas.dart index 731723eef..1b19d8ed6 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -37,13 +37,24 @@ class _CanvasControlState extends State { int _lastResize = DateTime.now().millisecondsSinceEpoch; Size? _lastSize; ui.Image? _capturedImage; - Size? _capturedSize; + double _dpr = 1.0; + bool _initialized = false; + @override void initState() { super.initState(); widget.control.addInvokeMethodListener(_invokeMethod); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _dpr = MediaQuery.devicePixelRatioOf(context); + _initialized = true; + } + } + @override void dispose() { widget.control.removeInvokeMethodListener(_invokeMethod); @@ -89,21 +100,23 @@ class _CanvasControlState extends State { final canvas = Canvas(recorder); final painter = FletCustomPainter( - context: context, - theme: Theme.of(context), - shapes: shapes, - onPaintCallback: (_) {}, - ); + context: context, + theme: Theme.of(context), + shapes: shapes, + capturedImage: _capturedImage, + onPaintCallback: (_) {}, + dpr: _dpr); - painter.paint(canvas, logicalSize); + var captureSize = + Size(logicalSize.width * _dpr, logicalSize.height * _dpr); + + painter.paint(canvas, captureSize); final picture = recorder.endRecording(); _capturedImage = await picture.toImage( - logicalSize.width.ceil(), - logicalSize.height.ceil(), + captureSize.width.toInt(), + captureSize.height.toInt(), ); - _capturedSize = logicalSize; - setState(() {}); // trigger rebuild return; case "get_capture": @@ -115,7 +128,6 @@ class _CanvasControlState extends State { case "clear_capture": _capturedImage?.dispose(); _capturedImage = null; - _capturedSize = null; setState(() {}); return; @@ -128,7 +140,6 @@ class _CanvasControlState extends State { Widget build(BuildContext context) { debugPrint("Canvas build: ${widget.control.id}"); - var onResize = widget.control.getBool("on_resize", false)!; var resizeInterval = widget.control.getInt("resize_interval", 10)!; var paint = CustomPaint( @@ -137,17 +148,15 @@ class _CanvasControlState extends State { theme: Theme.of(context), shapes: widget.control.children("shapes"), capturedImage: _capturedImage, - capturedSize: _capturedSize, + dpr: 1, onPaintCallback: (size) { - _lastSize = size; - if (onResize) { - var now = DateTime.now().millisecondsSinceEpoch; - if ((now - _lastResize > resizeInterval && _lastSize != size) || - _lastSize == null) { - _lastResize = now; - widget.control - .triggerEvent("resize", {"w": size.width, "h": size.height}); - } + var now = DateTime.now().millisecondsSinceEpoch; + if ((now - _lastResize > resizeInterval && _lastSize != size) || + _lastSize == null) { + _lastSize = size; + _lastResize = now; + widget.control + .triggerEvent("resize", {"w": size.width, "h": size.height}); } }, ), @@ -164,31 +173,31 @@ class FletCustomPainter extends CustomPainter { final List shapes; final CanvasControlOnPaintCallback onPaintCallback; final ui.Image? capturedImage; - final Size? capturedSize; + final double dpr; const FletCustomPainter({ required this.context, required this.theme, required this.shapes, required this.onPaintCallback, + required this.dpr, this.capturedImage, - this.capturedSize, }); @override void paint(Canvas canvas, Size size) { onPaintCallback(size); - //debugPrint("SHAPE CONTROLS: $shapes"); + debugPrint("paint.size: $size"); + //debugPrint("paint.shapes: $shapes"); canvas.save(); canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); - if (capturedImage != null && capturedSize != null) { + if (capturedImage != null) { final src = Rect.fromLTWH(0, 0, capturedImage!.width.toDouble(), capturedImage!.height.toDouble()); - final dst = - Rect.fromLTWH(0, 0, capturedSize!.width, capturedSize!.height); + final dst = Rect.fromLTWH(0, 0, size.width, size.height); canvas.drawImageRect(capturedImage!, src, dst, Paint()); } @@ -389,8 +398,10 @@ class FletCustomPainter extends CustomPainter { 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()); + ? Rect.fromLTWH(x * dpr, y * dpr, width * dpr, height * dpr) + : Offset(x * dpr, y * dpr) & + Size(img.width.toDouble() * dpr, img.height.toDouble() * dpr); + debugPrint("canvas.drawImageRect($srcRect, $dstRect)"); canvas.drawImageRect(img, srcRect, dstRect, paint); } else { loadCanvasImage(shape); @@ -476,8 +487,6 @@ Future loadCanvasImage(Control shape) async { 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; @@ -502,11 +511,7 @@ Future loadCanvasImage(Control shape) async { throw Exception("Missing image source: 'src' or 'src_bytes'"); } - final codec = await ui.instantiateImageCodec( - bytes, - targetWidth: width, - targetHeight: height, - ); + final codec = await ui.instantiateImageCodec(bytes); final frame = await codec.getNextFrame(); shape.properties["_image"] = frame.image; shape.updateProperties({"_hash": getImageHash(shape)}, From f4827698fdd19a08a47d826bc5b706cec44b7846 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 17 Jul 2025 16:51:32 -0700 Subject: [PATCH 06/11] BaseControl. _trigger_event, context moved to a separate module --- .../flet-web/src/flet_web/fastapi/flet_app.py | 5 +- sdk/python/packages/flet/src/flet/__init__.py | 2 +- sdk/python/packages/flet/src/flet/app.py | 5 +- .../flet/src/flet/controls/base_control.py | 76 ++++++++++++++++++ .../flet/src/flet/controls/context.py | 15 ++++ .../packages/flet/src/flet/controls/page.py | 16 +--- .../flet/src/flet/messaging/session.py | 77 ++----------------- 7 files changed, 108 insertions(+), 88 deletions(-) create mode 100644 sdk/python/packages/flet/src/flet/controls/context.py diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py index 97019f86e..d894d91c1 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py @@ -11,7 +11,8 @@ import msgpack from fastapi import WebSocket, WebSocketDisconnect from flet.controls.base_control import BaseControl -from flet.controls.page import PageDisconnectedException, _session_page +from flet.controls.context import _context_page +from flet.controls.page import PageDisconnectedException from flet.controls.update_behavior import UpdateBehavior from flet.messaging.connection import Connection from flet.messaging.protocol import ( @@ -138,7 +139,7 @@ async def __on_session_created(self): logger.info(f"Start session: {self.__session.id}") try: assert self.__main is not None - _session_page.set(self.__session.page) + _context_page.set(self.__session.page) UpdateBehavior.reset() if asyncio.iscoroutinefunction(self.__main): diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index c0575635f..f1f9d1d6b 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -357,8 +357,8 @@ PageDisconnectedException, RouteChangeEvent, ViewPopEvent, - context, ) +from flet.controls.context import context from flet.controls.page_view import PageMediaData, PageResizeEvent, PageView from flet.controls.painting import ( Paint, diff --git a/sdk/python/packages/flet/src/flet/app.py b/sdk/python/packages/flet/src/flet/app.py index 90636aabc..47d65309e 100644 --- a/sdk/python/packages/flet/src/flet/app.py +++ b/sdk/python/packages/flet/src/flet/app.py @@ -10,7 +10,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Optional, Union -from flet.controls.page import Page, _session_page +from flet.controls.context import _context_page +from flet.controls.page import Page from flet.controls.types import AppView, RouteUrlStrategy, WebRenderer from flet.controls.update_behavior import UpdateBehavior from flet.messaging.session import Session @@ -253,7 +254,7 @@ async def on_session_created(session: Session): logger.info("App session started") try: assert main is not None - _session_page.set(session.page) + _context_page.set(session.page) UpdateBehavior.reset() if asyncio.iscoroutinefunction(main): await main(session.page) diff --git a/sdk/python/packages/flet/src/flet/controls/base_control.py b/sdk/python/packages/flet/src/flet/controls/base_control.py index d3ad046d0..01360f709 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_control.py +++ b/sdk/python/packages/flet/src/flet/controls/base_control.py @@ -1,12 +1,18 @@ +import asyncio +import inspect import logging import sys from dataclasses import InitVar, dataclass, field from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union +from flet.controls.context import _context_page from flet.controls.control_event import ControlEvent from flet.controls.control_id import ControlId from flet.controls.keys import ScrollKey, ValueKey from flet.controls.ref import Ref +from flet.controls.update_behavior import UpdateBehavior +from flet.utils.from_dict import from_dict +from flet.utils.object_model import get_param_count logger = logging.getLogger("flet") controls_log = logging.getLogger("flet_controls") @@ -210,3 +216,73 @@ async def _invoke_method_async( return await self.page.get_session().invoke_method( self._i, method_name, arguments, timeout ) + + async def _trigger_event(self, event_name: str, event_data: Any): + field_name = f"on_{event_name}" + if not hasattr(self, field_name): + # field_name not defined + return + + event_type = ControlEvent.get_event_field_type(self, field_name) + if event_type is None: + return + + if event_type == ControlEvent or not isinstance(event_data, dict): + # simple ControlEvent + e = ControlEvent(control=self, name=event_name, data=event_data) + else: + # custom ControlEvent + args = { + "control": self, + "name": event_name, + **(event_data or {}), + } + e = from_dict(event_type, args) + + handle_event = self.before_event(e) + + if handle_event is None or handle_event: + _context_page.set(self.page) + UpdateBehavior.reset() + + assert self.page, "Control must be added on a page first." + session = self.page.get_session() + + # Handle async and sync event handlers accordingly + event_handler = getattr(self, field_name) + if asyncio.iscoroutinefunction(event_handler): + if get_param_count(event_handler) == 0: + await event_handler() + else: + await event_handler(e) + + elif inspect.isasyncgenfunction(event_handler): + if get_param_count(event_handler) == 0: + async for _ in event_handler(): + if UpdateBehavior.auto_update_enabled(): + await session.auto_update(session.index.get(self._i)) + else: + async for _ in event_handler(e): + if UpdateBehavior.auto_update_enabled(): + await session.auto_update(session.index.get(self._i)) + return + + elif inspect.isgeneratorfunction(event_handler): + if get_param_count(event_handler) == 0: + for _ in event_handler(): + if UpdateBehavior.auto_update_enabled(): + await session.auto_update(session.index.get(self._i)) + else: + for _ in event_handler(e): + if UpdateBehavior.auto_update_enabled(): + await session.auto_update(session.index.get(self._i)) + return + + elif callable(event_handler): + if get_param_count(event_handler) == 0: + event_handler() + else: + event_handler(e) + + if UpdateBehavior.auto_update_enabled(): + await session.auto_update(session.index.get(self._i)) diff --git a/sdk/python/packages/flet/src/flet/controls/context.py b/sdk/python/packages/flet/src/flet/controls/context.py new file mode 100644 index 000000000..2f797669f --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/context.py @@ -0,0 +1,15 @@ +from contextvars import ContextVar +from typing import TYPE_CHECKING, Optional + +from flet.utils.classproperty import classproperty + +if TYPE_CHECKING: + from flet.controls.page import Page + +_context_page = ContextVar("flet_session_page", default=None) + + +class context: + @classproperty + def page(cls) -> Optional["Page"]: + return _context_page.get() diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 230534785..69254662e 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -3,7 +3,6 @@ import weakref from collections.abc import Awaitable, Coroutine from concurrent.futures import CancelledError, Future, ThreadPoolExecutor -from contextvars import ContextVar from dataclasses import InitVar, dataclass, field from functools import partial from typing import ( @@ -19,6 +18,7 @@ from flet.auth.oauth_provider import OAuthProvider from flet.controls.adaptive_control import AdaptiveControl from flet.controls.base_control import BaseControl, control +from flet.controls.context import _context_page from flet.controls.control import Control from flet.controls.control_event import ( ControlEvent, @@ -46,7 +46,7 @@ PagePlatform, Wrapper, ) -from flet.utils import classproperty, is_pyodide +from flet.utils import is_pyodide from flet.utils.strings import random_string if not is_pyodide(): @@ -72,14 +72,6 @@ except ImportError: from typing_extensions import ParamSpec -_session_page = ContextVar("flet_session_page", default=None) - - -class context: - @classproperty - def page(cls) -> Optional["Page"]: - return _session_page.get() - AT = TypeVar("AT", bound=Authorization) InputT = ParamSpec("InputT") @@ -479,7 +471,7 @@ def run_task( Run `handler` coroutine as a new Task in the event loop associated with the current page. """ - _session_page.set(self) + _context_page.set(self) assert asyncio.iscoroutinefunction(handler) future = asyncio.run_coroutine_threadsafe( @@ -500,7 +492,7 @@ def _on_completion(f): def __context_wrapper(self, handler: Callable[..., Any]) -> Wrapper: def wrapper(*args, **kwargs): - _session_page.set(self) + _context_page.set(self) handler(*args, **kwargs) return wrapper diff --git a/sdk/python/packages/flet/src/flet/messaging/session.py b/sdk/python/packages/flet/src/flet/messaging/session.py index 2a9a602df..fe603de57 100644 --- a/sdk/python/packages/flet/src/flet/messaging/session.py +++ b/sdk/python/packages/flet/src/flet/messaging/session.py @@ -1,5 +1,4 @@ import asyncio -import inspect import logging import traceback import weakref @@ -7,10 +6,9 @@ from typing import Any, Optional from flet.controls.base_control import BaseControl -from flet.controls.control_event import ControlEvent +from flet.controls.context import _context_page from flet.controls.object_patch import ObjectPatch -from flet.controls.page import Page, _session_page -from flet.controls.update_behavior import UpdateBehavior +from flet.controls.page import Page from flet.messaging.connection import Connection from flet.messaging.protocol import ( ClientAction, @@ -20,8 +18,7 @@ SessionCrashedBody, ) from flet.pubsub.pubsub_client import PubSubClient -from flet.utils.from_dict import from_dict -from flet.utils.object_model import get_param_count, patch_dataclass +from flet.utils.object_model import patch_dataclass from flet.utils.strings import random_string logger = logging.getLogger("flet") @@ -75,7 +72,7 @@ def pubsub_client(self) -> PubSubClient: async def connect(self, conn: Connection) -> None: logger.debug(f"Connect session: {self.id}") - _session_page.set(self.__page) + _context_page.set(self.__page) self.__conn = conn self.__expires_at = None for message in self.__send_buffer: @@ -167,73 +164,11 @@ async def dispatch_event( logger.debug(f"Control with ID {control_id} not found.") return - field_name = f"on_{event_name}" - if not hasattr(control, field_name): - # field_name not defined - return try: - event_type = ControlEvent.get_event_field_type(control, field_name) - if event_type is None: - return - - if event_type == ControlEvent or not isinstance(event_data, dict): - # simple ControlEvent - e = ControlEvent(control=control, name=event_name, data=event_data) - else: - # custom ControlEvent - args = { - "control": control, - "name": event_name, - **(event_data or {}), - } - e = from_dict(event_type, args) - - handle_event = control.before_event(e) - - if handle_event is None or handle_event: - _session_page.set(self.__page) - UpdateBehavior.reset() - - # Handle async and sync event handlers accordingly - event_handler = getattr(control, field_name) - if asyncio.iscoroutinefunction(event_handler): - if get_param_count(event_handler) == 0: - await event_handler() - else: - await event_handler(e) - - elif inspect.isasyncgenfunction(event_handler): - if get_param_count(event_handler) == 0: - async for _ in event_handler(): - if UpdateBehavior.auto_update_enabled(): - await self.auto_update(self.index.get(control._i)) - else: - async for _ in event_handler(e): - if UpdateBehavior.auto_update_enabled(): - await self.auto_update(self.index.get(control._i)) - - elif inspect.isgeneratorfunction(event_handler): - if get_param_count(event_handler) == 0: - for _ in event_handler(): - if UpdateBehavior.auto_update_enabled(): - await self.auto_update(self.index.get(control._i)) - else: - for _ in event_handler(e): - if UpdateBehavior.auto_update_enabled(): - await self.auto_update(self.index.get(control._i)) - - elif callable(event_handler): - if get_param_count(event_handler) == 0: - event_handler() - else: - event_handler(e) - - if UpdateBehavior.auto_update_enabled(): - await self.auto_update(self.index.get(control._i)) - + await control._trigger_event(event_name, event_data) except Exception as ex: tb = traceback.format_exc() - self.error(f"Exception in '{field_name}': {ex}\n{tb}") + self.error(f"Exception in 'on_{event_name}': {ex}\n{tb}") async def invoke_method( self, From 236c4fe2be3327ce82a0e6add004632bdf6dd6ea Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 17 Jul 2025 20:09:52 -0700 Subject: [PATCH 07/11] Scale canvas to account for DPR when taking capture --- packages/flet/lib/src/controls/canvas.dart | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/flet/lib/src/controls/canvas.dart b/packages/flet/lib/src/controls/canvas.dart index 1b19d8ed6..b8ae84ec9 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -35,7 +35,7 @@ class CanvasControl extends StatefulWidget { class _CanvasControlState extends State { int _lastResize = DateTime.now().millisecondsSinceEpoch; - Size? _lastSize; + Size _lastSize = Size.zero; ui.Image? _capturedImage; double _dpr = 1.0; bool _initialized = false; @@ -86,8 +86,7 @@ class _CanvasControlState extends State { switch (name) { case "capture": final shapes = widget.control.children("shapes"); - final logicalSize = _lastSize; - if (logicalSize == null || logicalSize.isEmpty || shapes.isEmpty) { + if (_lastSize.isEmpty || shapes.isEmpty) { return; } @@ -107,15 +106,12 @@ class _CanvasControlState extends State { onPaintCallback: (_) {}, dpr: _dpr); - var captureSize = - Size(logicalSize.width * _dpr, logicalSize.height * _dpr); - - painter.paint(canvas, captureSize); + painter.paint(canvas, _lastSize); final picture = recorder.endRecording(); _capturedImage = await picture.toImage( - captureSize.width.toInt(), - captureSize.height.toInt(), + (_lastSize.width * _dpr).toInt(), + (_lastSize.height * _dpr).toInt(), ); return; @@ -152,7 +148,7 @@ class _CanvasControlState extends State { onPaintCallback: (size) { var now = DateTime.now().millisecondsSinceEpoch; if ((now - _lastResize > resizeInterval && _lastSize != size) || - _lastSize == null) { + _lastSize.isEmpty) { _lastSize = size; _lastResize = now; widget.control @@ -192,6 +188,7 @@ class FletCustomPainter extends CustomPainter { //debugPrint("paint.shapes: $shapes"); canvas.save(); + canvas.scale(dpr); canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); if (capturedImage != null) { @@ -398,9 +395,8 @@ class FletCustomPainter extends CustomPainter { final srcRect = Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()); final dstRect = width != null && height != null - ? Rect.fromLTWH(x * dpr, y * dpr, width * dpr, height * dpr) - : Offset(x * dpr, y * dpr) & - Size(img.width.toDouble() * dpr, img.height.toDouble() * dpr); + ? Rect.fromLTWH(x, y, width, height) + : Offset(x, y) & Size(img.width.toDouble(), img.height.toDouble()); debugPrint("canvas.drawImageRect($srcRect, $dstRect)"); canvas.drawImageRect(img, srcRect, dstRect, paint); } else { From 5d55afb668b1fbca055b11b28aef94269337d4f1 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 20 Jul 2025 12:20:48 -0700 Subject: [PATCH 08/11] Preserve capture size --- packages/flet/lib/src/controls/canvas.dart | 29 ++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/flet/lib/src/controls/canvas.dart b/packages/flet/lib/src/controls/canvas.dart index b8ae84ec9..aa306f33d 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -37,6 +37,7 @@ class _CanvasControlState extends State { int _lastResize = DateTime.now().millisecondsSinceEpoch; Size _lastSize = Size.zero; ui.Image? _capturedImage; + Size _capturedSize = Size.zero; double _dpr = 1.0; bool _initialized = false; @@ -98,11 +99,15 @@ class _CanvasControlState extends State { final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); + var capturedSize = + _capturedSize != Size.zero ? _capturedSize : _lastSize; + final painter = FletCustomPainter( context: context, theme: Theme.of(context), shapes: shapes, capturedImage: _capturedImage, + capturedSize: capturedSize, onPaintCallback: (_) {}, dpr: _dpr); @@ -113,6 +118,7 @@ class _CanvasControlState extends State { (_lastSize.width * _dpr).toInt(), (_lastSize.height * _dpr).toInt(), ); + _capturedSize = _lastSize; return; case "get_capture": @@ -144,6 +150,7 @@ class _CanvasControlState extends State { theme: Theme.of(context), shapes: widget.control.children("shapes"), capturedImage: _capturedImage, + capturedSize: _capturedSize, dpr: 1, onPaintCallback: (size) { var now = DateTime.now().millisecondsSinceEpoch; @@ -169,16 +176,17 @@ class FletCustomPainter extends CustomPainter { final List shapes; final CanvasControlOnPaintCallback onPaintCallback; final ui.Image? capturedImage; + final Size? capturedSize; final double dpr; - const FletCustomPainter({ - required this.context, - required this.theme, - required this.shapes, - required this.onPaintCallback, - required this.dpr, - this.capturedImage, - }); + const FletCustomPainter( + {required this.context, + required this.theme, + required this.shapes, + required this.onPaintCallback, + required this.dpr, + this.capturedImage, + this.capturedSize}); @override void paint(Canvas canvas, Size size) { @@ -191,10 +199,11 @@ class FletCustomPainter extends CustomPainter { canvas.scale(dpr); canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); - if (capturedImage != null) { + if (capturedImage != null && capturedSize != null) { final src = Rect.fromLTWH(0, 0, capturedImage!.width.toDouble(), capturedImage!.height.toDouble()); - final dst = Rect.fromLTWH(0, 0, size.width, size.height); + final dst = + Rect.fromLTWH(0, 0, capturedSize!.width, capturedSize!.height); canvas.drawImageRect(capturedImage!, src, dst, Paint()); } From e4837dd696217e95be696fe99136703000f434e5 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 20 Jul 2025 13:50:07 -0700 Subject: [PATCH 09/11] Fix: page.page should return Page. --- sdk/python/packages/flet/src/flet/controls/base_control.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/packages/flet/src/flet/controls/base_control.py b/sdk/python/packages/flet/src/flet/controls/base_control.py index 01360f709..28773caf9 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_control.py +++ b/sdk/python/packages/flet/src/flet/controls/base_control.py @@ -163,7 +163,7 @@ def page(self) -> Optional[Union["Page", "PageView"]]: """The page (of type `Page` or `PageView`) to which this control belongs to.""" from .page import Page, PageView - parent = self.parent + parent = self while parent: if isinstance(parent, (Page, PageView)): return parent From d9e7e8b31a11f815692a250e11c98a15e05cb3cf Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 20 Jul 2025 17:01:25 -0700 Subject: [PATCH 10/11] KeyboardListener control --- .../lib/src/controls/keyboard_listener.dart | 71 ++++++++++++++++ .../flet/lib/src/flet_core_extension.dart | 3 + sdk/python/packages/flet/src/flet/__init__.py | 12 ++- .../flet/controls/core/keyboard_listener.py | 81 +++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 packages/flet/lib/src/controls/keyboard_listener.dart create mode 100644 sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py diff --git a/packages/flet/lib/src/controls/keyboard_listener.dart b/packages/flet/lib/src/controls/keyboard_listener.dart new file mode 100644 index 000000000..dd0b787f6 --- /dev/null +++ b/packages/flet/lib/src/controls/keyboard_listener.dart @@ -0,0 +1,71 @@ +import 'package:flet/flet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class KeyboardListenerControl extends StatefulWidget { + final Control control; + + KeyboardListenerControl({Key? key, required this.control}) + : super(key: ValueKey("control_${control.id}")); + + @override + State createState() => + _KeyboardListenerControlState(); +} + +class _KeyboardListenerControlState extends State { + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + widget.control.addInvokeMethodListener(_invokeMethod); + } + + @override + void dispose() { + _focusNode.dispose(); + widget.control.removeInvokeMethodListener(_invokeMethod); + super.dispose(); + } + + Future _invokeMethod(String name, dynamic args) async { + debugPrint("KeyboardListener.$name($args)"); + switch (name) { + case "focus": + _focusNode.requestFocus(); + default: + throw Exception("Unknown KeyboardListener method: $name"); + } + } + + @override + Widget build(BuildContext context) { + debugPrint("KeyboardListener build: ${widget.control.id}"); + + var content = widget.control.buildWidget("content"); + + if (content == null) { + return const ErrorControl("KeyboardListener control has no content."); + } + + return KeyboardListener( + focusNode: _focusNode, + autofocus: widget.control.getBool("autofocus", false)!, + includeSemantics: widget.control.getBool("include_semantics", true)!, + onKeyEvent: (keyEvent) { + if (keyEvent is KeyDownEvent) { + widget.control + .triggerEvent("key_down", {"key": keyEvent.logicalKey.keyLabel}); + } else if (keyEvent is KeyUpEvent) { + widget.control + .triggerEvent("key_up", {"key": keyEvent.logicalKey.keyLabel}); + } else if (keyEvent is KeyRepeatEvent) { + widget.control.triggerEvent( + "key_repeat", {"key": keyEvent.logicalKey.keyLabel}); + } + }, + child: content, + ); + } +} diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index 649b33975..33706564b 100644 --- a/packages/flet/lib/src/flet_core_extension.dart +++ b/packages/flet/lib/src/flet_core_extension.dart @@ -61,6 +61,7 @@ import 'controls/icon.dart'; import 'controls/icon_button.dart'; import 'controls/image.dart'; import 'controls/interactive_viewer.dart'; +import 'controls/keyboard_listener.dart'; import 'controls/list_tile.dart'; import 'controls/list_view.dart'; import 'controls/markdown.dart'; @@ -244,6 +245,8 @@ class FletCoreExtension extends FletExtension { return AdaptiveButtonControl(key: key, control: control); case "FletApp": return FletAppControl(key: key, control: control); + case "KeyboardListener": + return KeyboardListenerControl(key: key, control: control); case "SubmenuButton": return SubmenuButtonControl(key: key, control: control); case "FloatingActionButton": diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index f1f9d1d6b..aa6f5815b 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -71,6 +71,7 @@ ) from flet.controls.colors import Colors from flet.controls.constrained_control import ConstrainedControl +from flet.controls.context import context from flet.controls.control import Control, OptionalControl from flet.controls.control_builder import ControlBuilder from flet.controls.control_event import ( @@ -114,6 +115,12 @@ from flet.controls.core.icon import Icon from flet.controls.core.image import Image from flet.controls.core.interactive_viewer import InteractiveViewer +from flet.controls.core.keyboard_listener import ( + KeyboardListener, + KeyDownEvent, + KeyRepeatEvent, + KeyUpEvent, +) from flet.controls.core.list_view import ListView from flet.controls.core.markdown import ( Markdown, @@ -358,7 +365,6 @@ RouteChangeEvent, ViewPopEvent, ) -from flet.controls.context import context from flet.controls.page_view import PageMediaData, PageResizeEvent, PageView from flet.controls.painting import ( Paint, @@ -972,4 +978,8 @@ "EventControlType", "TextSelectionChangeCause", "TextSelectionChangeEvent", + "KeyboardListener", + "KeyDownEvent", + "KeyRepeatEvent", + "KeyUpEvent", ] diff --git a/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py b/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py new file mode 100644 index 000000000..edc0cfc79 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py @@ -0,0 +1,81 @@ +import asyncio +from dataclasses import dataclass + +from flet.controls.base_control import control +from flet.controls.control import Control +from flet.controls.control_event import Event, OptionalEventHandler + +__all__ = [ + "KeyboardListener", +] + + +@dataclass +class KeyDownEvent(Event["KeyboardListener"]): + key: str + + +@dataclass +class KeyUpEvent(Event["KeyboardListener"]): + key: str + + +@dataclass +class KeyRepeatEvent(Event["KeyboardListener"]): + key: str + + +@control("KeyboardListener") +class KeyboardListener(Control): + """ + A control that calls a callback whenever the user presses or releases + a key on a keyboard. + + Online docs: https://flet.dev/docs/controls/keyboardlistener + """ + + content: Control + """ + The content control of the keyboard listener. + """ + + autofocus: bool = False + """ + True if this control will be selected as the initial focus when no other node + in its scope is currently focused. + """ + + include_semantics: bool = True + """ + Include semantics information in this control. + """ + + on_key_down: OptionalEventHandler[KeyDownEvent] = None + """ + Fires when a keyboard key is pressed. + + Event handler argument is of type + [KeyboardEvent](https://flet.dev/docs/reference/types/keydownvent). + """ + + on_key_up: OptionalEventHandler[KeyUpEvent] = None + """ + Fires when a keyboard key is released. + + Event handler argument is of type + [KeyboardEvent](https://flet.dev/docs/reference/types/keyupevent). + """ + + on_key_repeat: OptionalEventHandler[KeyRepeatEvent] = None + """ + Fires when a keyboard key is being hold, causing repeated events. + + Event handler argument is of type + [KeyboardEvent](https://flet.dev/docs/reference/types/keyrepeatevent). + """ + + async def focus_async(self): + await self._invoke_method_async("focus") + + def focus(self): + asyncio.create_task(self.focus_async()) From 1b63fa71190e7bf9112782b180642db41d24d385 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 20 Jul 2025 18:22:44 -0700 Subject: [PATCH 11/11] Allow FilePicker.save_file on web --- packages/flet/lib/src/services/file_picker.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/flet/lib/src/services/file_picker.dart b/packages/flet/lib/src/services/file_picker.dart index 92d9ed728..a70acefec 100644 --- a/packages/flet/lib/src/services/file_picker.dart +++ b/packages/flet/lib/src/services/file_picker.dart @@ -67,11 +67,14 @@ class FilePickerService extends FletService { }).toList() : []; case "save_file": - if (kIsWeb) { - throw Exception("Save File dialog is not supported on web."); - } else if ((isAndroidMobile() || isIOSMobile()) && srcBytes == null) { + if ((kIsWeb || isAndroidMobile() || isIOSMobile()) && + srcBytes == null) { + throw Exception( + "\"src_bytes\" is required when saving a file on Web, Android and iOS."); + } + if (kIsWeb && args["file_name"] == null) { throw Exception( - "\"src_bytes\" is required when saving a file on Android or iOS."); + "\"file_name\" is required when saving a file on Web."); } return await FilePicker.platform.saveFile( dialogTitle: dialogTitle,