From 86b7e90f7a986ae644484d6d2215f35cce980f20 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 11 Jul 2025 13:41:12 -0700 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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, From 1041f4811a3f03bc773ef8033e03c97e8983d289 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 5 Aug 2025 15:52:36 -0700 Subject: [PATCH 12/16] Update right-pan event --- .../flet/lib/src/controls/gesture_detector.dart | 13 +++++-------- .../flet/src/flet/controls/core/canvas/image.py | 12 ++++++------ .../src/flet/controls/core/keyboard_listener.py | 9 +++++---- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/flet/lib/src/controls/gesture_detector.dart b/packages/flet/lib/src/controls/gesture_detector.dart index dada30fa2..4fb3371f8 100644 --- a/packages/flet/lib/src/controls/gesture_detector.dart +++ b/packages/flet/lib/src/controls/gesture_detector.dart @@ -38,8 +38,7 @@ class _GestureDetectorControlState extends State { Timer? _debounce; bool _rightPanActive = false; int _rightPanTimestamp = DateTime.now().millisecondsSinceEpoch; - double _rightPanStartX = 0.0; - double _rightPanStartY = 0.0; + Offset _rightPanStart = Offset.zero; @override void initState() { @@ -386,8 +385,7 @@ class _GestureDetectorControlState extends State { if (event.kind == PointerDeviceKind.mouse && event.buttons == kSecondaryMouseButton) { _rightPanActive = true; - _rightPanStartX = event.localPosition.dx; - _rightPanStartY = event.localPosition.dy; + _rightPanStart = event.localPosition; widget.control.triggerEvent("right_pan_start", event.toMap()); } } @@ -398,10 +396,9 @@ class _GestureDetectorControlState extends State { 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; + widget.control.triggerEvent( + "right_pan_update", event.toMap(_rightPanStart)); + _rightPanStart = event.localPosition; } } } 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 index 2bbe3f105..ee163ee9c 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py +++ b/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py @@ -3,7 +3,7 @@ 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 +from flet.controls.types import Number @control("Image") @@ -12,7 +12,7 @@ class Image(Shape): Draws an image. """ - src: OptionalString = None + src: Optional[str] = None """ Draws an image from a source. @@ -25,22 +25,22 @@ class Image(Shape): Draws an image from a bytes array. """ - x: OptionalNumber = None + x: Optional[Number] = None """ The x-axis coordinate of the image's top-left corner. """ - y: OptionalNumber = None + y: Optional[Number] = None """ The y-axis coordinate of the image's top-left corner. """ - width: OptionalNumber = None + width: Optional[Number] = None """ The width of the rectangle to draw the image into. Use image width if None. """ - height: OptionalNumber = None + height: Optional[Number] = None """ The height of the rectangle to draw the image into. Use image height if None. """ 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 index edc0cfc79..941744b99 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py +++ b/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py @@ -1,9 +1,10 @@ import asyncio from dataclasses import dataclass +from typing import Optional from flet.controls.base_control import control from flet.controls.control import Control -from flet.controls.control_event import Event, OptionalEventHandler +from flet.controls.control_event import Event, EventHandler __all__ = [ "KeyboardListener", @@ -50,7 +51,7 @@ class KeyboardListener(Control): Include semantics information in this control. """ - on_key_down: OptionalEventHandler[KeyDownEvent] = None + on_key_down: Optional[EventHandler[KeyDownEvent]] = None """ Fires when a keyboard key is pressed. @@ -58,7 +59,7 @@ class KeyboardListener(Control): [KeyboardEvent](https://flet.dev/docs/reference/types/keydownvent). """ - on_key_up: OptionalEventHandler[KeyUpEvent] = None + on_key_up: Optional[EventHandler[KeyUpEvent]] = None """ Fires when a keyboard key is released. @@ -66,7 +67,7 @@ class KeyboardListener(Control): [KeyboardEvent](https://flet.dev/docs/reference/types/keyupevent). """ - on_key_repeat: OptionalEventHandler[KeyRepeatEvent] = None + on_key_repeat: Optional[EventHandler[KeyRepeatEvent]] = None """ Fires when a keyboard key is being hold, causing repeated events. From b18b3d3df798d596fdbdd301b72dbd5b73aff105 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 5 Aug 2025 16:48:24 -0700 Subject: [PATCH 13/16] All canvas shapes can be dashed Close #1791 --- packages/flet/lib/src/controls/canvas.dart | 104 ++++++++++---- .../examples/controls/canvas/strokes.py | 130 ++++++++++++++++++ .../src/flet/controls/core/canvas/image.py | 3 +- 3 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 sdk/python/examples/controls/canvas/strokes.py diff --git a/packages/flet/lib/src/controls/canvas.dart b/packages/flet/lib/src/controls/canvas.dart index afb274fed..717735f96 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -248,47 +248,84 @@ class FletCustomPainter extends CustomPainter { Paint paint = shape.getPaint("paint", theme, Paint())!; var dashPattern = shape.getPaintStrokeDashPattern("paint"); paint.style = ui.PaintingStyle.stroke; - var path = ui.Path(); - path.moveTo(shape.getDouble("x1")!, shape.getDouble("y1")!); - path.lineTo(shape.getDouble("x2")!, shape.getDouble("y2")!); - if (dashPattern != null) { + var p1 = Offset(shape.getDouble("x1")!, shape.getDouble("y1")!); + var p2 = Offset(shape.getDouble("x2")!, shape.getDouble("y2")!); + + if (dashPattern == null) { + canvas.drawLine(p1, p2, paint); + } else { + var path = ui.Path(); + path.moveTo(p1.dx, p1.dy); + path.lineTo(p2.dx, p2.dy); path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); + canvas.drawPath(path, paint); } - canvas.drawPath(path, paint); } void drawCircle(Canvas canvas, Control shape) { + var x = shape.getDouble("x")!; + var y = shape.getDouble("y")!; var radius = shape.getDouble("radius", 0)!; Paint paint = shape.getPaint("paint", theme, Paint())!; - canvas.drawCircle( - Offset(shape.getDouble("x")!, shape.getDouble("y")!), radius, paint); + + var dashPattern = shape.getPaintStrokeDashPattern("paint"); + + if (dashPattern == null) { + canvas.drawCircle(Offset(x, y), radius, paint); + } else { + var path = ui.Path(); + path.addOval(Rect.fromCircle(center: Offset(x, y), radius: radius)); + path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); + canvas.drawPath(path, paint); + } } void drawOval(Canvas canvas, Control shape) { + var x = shape.getDouble("x")!; + var y = shape.getDouble("y")!; var width = shape.getDouble("width", 0)!; var height = shape.getDouble("height", 0)!; Paint paint = shape.getPaint("paint", theme, Paint())!; - canvas.drawOval( - Rect.fromLTWH( - shape.getDouble("x")!, shape.getDouble("y")!, width, height), - paint); + var dashPattern = shape.getPaintStrokeDashPattern("paint"); + + if (dashPattern == null) { + canvas.drawOval(Rect.fromLTWH(x, y, width, height), paint); + } else { + var path = ui.Path(); + path.addOval(Rect.fromLTWH(x, y, width, height)); + path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); + canvas.drawPath(path, paint); + } } void drawArc(Canvas canvas, Control shape) { + var x = shape.getDouble("x")!; + var y = shape.getDouble("y")!; var width = shape.getDouble("width", 0)!; var height = shape.getDouble("height", 0)!; var startAngle = shape.getDouble("start_angle", 0)!; var sweepAngle = shape.getDouble("sweep_angle", 0)!; var useCenter = shape.getBool("use_center", false)!; Paint paint = shape.getPaint("paint", theme, Paint())!; - canvas.drawArc( - Rect.fromLTWH( - shape.getDouble("x")!, shape.getDouble("y")!, width, height), - startAngle, - sweepAngle, - useCenter, - paint); + + var dashPattern = shape.getPaintStrokeDashPattern("paint"); + if (dashPattern == null) { + canvas.drawArc(Rect.fromLTWH(x, y, width, height), startAngle, sweepAngle, + useCenter, paint); + } else { + var path = ui.Path(); + if (useCenter) { + path.moveTo(x + width / 2, y + height / 2); + path.arcTo( + Rect.fromLTWH(x, y, width, height), startAngle, sweepAngle, false); + path.close(); + } else { + path.addArc(Rect.fromLTWH(x, y, width, height), startAngle, sweepAngle); + } + path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); + canvas.drawPath(path, paint); + } } void drawFill(Canvas canvas, Control shape) { @@ -315,20 +352,33 @@ class FletCustomPainter extends CustomPainter { } void drawRect(Canvas canvas, Control shape) { + var x = shape.getDouble("x")!; + var y = shape.getDouble("y")!; var width = shape.getDouble("width", 0)!; var height = shape.getDouble("height", 0)!; var borderRadius = shape.getBorderRadius("border_radius", BorderRadius.zero)!; Paint paint = shape.getPaint("paint", theme, Paint())!; - canvas.drawRRect( - RRect.fromRectAndCorners( - Rect.fromLTWH( - shape.getDouble("x")!, shape.getDouble("y")!, width, height), - topLeft: borderRadius.topLeft, - topRight: borderRadius.topRight, - bottomLeft: borderRadius.bottomLeft, - bottomRight: borderRadius.bottomRight), - paint); + var dashPattern = shape.getPaintStrokeDashPattern("paint"); + + if (dashPattern == null) { + canvas.drawRRect( + RRect.fromRectAndCorners(Rect.fromLTWH(x, y, width, height), + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight), + paint); + } else { + var path = ui.Path(); + path.addRRect(RRect.fromRectAndCorners(Rect.fromLTWH(x, y, width, height), + topLeft: borderRadius.topLeft, + topRight: borderRadius.topRight, + bottomLeft: borderRadius.bottomLeft, + bottomRight: borderRadius.bottomRight)); + path = dashPath(path, dashArray: CircularIntervalList(dashPattern)); + canvas.drawPath(path, paint); + } } void drawText(BuildContext context, Canvas canvas, Control shape) { diff --git a/sdk/python/examples/controls/canvas/strokes.py b/sdk/python/examples/controls/canvas/strokes.py new file mode 100644 index 000000000..a3d5c5fd7 --- /dev/null +++ b/sdk/python/examples/controls/canvas/strokes.py @@ -0,0 +1,130 @@ +import math +from dataclasses import dataclass + +import flet as ft +import flet.canvas as fc + + +@dataclass +class AppState: + strokes: bool + + def toggle_strokes(self): + self.strokes = not self.strokes + + +def main(page: ft.Page): + state = AppState(strokes=False) + + page.add( + ft.ControlBuilder( + state, + lambda state: ft.SafeArea( + ft.Column( + [ + ft.Button("Toggle strokes", on_click=state.toggle_strokes), + fc.Canvas( + [ + fc.Line( + 30, + 30, + 200, + 100, + ft.Paint( + color=ft.Colors.BLACK, + stroke_width=3, + stroke_dash_pattern=[4, 4] + if state.strokes + else None, + style=ft.PaintingStyle.STROKE, + ), + ), + fc.Circle( + 150, + 150, + 130, + ft.Paint( + color=ft.Colors.BLUE, + stroke_width=4, + stroke_dash_pattern=[4, 4] + if state.strokes + else None, + style=ft.PaintingStyle.STROKE, + ), + ), + fc.Oval( + 10, + 10, + 240, + 140, + paint=ft.Paint( + color=ft.Colors.GREEN, + stroke_width=4, + stroke_dash_pattern=[10, 10] + if state.strokes + else None, + style=ft.PaintingStyle.STROKE, + ), + ), + fc.Arc( + 20, + 20, + 220, + 220, + 0, + math.pi, + paint=ft.Paint( + color=ft.Colors.RED, + stroke_width=4, + stroke_dash_pattern=[7, 7] + if state.strokes + else None, + style=ft.PaintingStyle.STROKE, + ), + ), + fc.Rect( + 40, + 60, + 60, + 70, + 0, + paint=ft.Paint( + color=ft.Colors.RED, + stroke_width=4, + stroke_dash_pattern=[7, 7] + if state.strokes + else None, + style=ft.PaintingStyle.STROKE, + ), + ), + fc.Arc( + 50, + 50, + 170, + 140, + math.pi * 0.1, + math.pi * 0.4, + paint=ft.Paint( + color=ft.Colors.YELLOW, + stroke_width=4, + stroke_dash_pattern=[7, 7] + if state.strokes + else None, + style=ft.PaintingStyle.STROKE, + ), + use_center=True, + ), + ], + width=300, + height=300, + ), + ] + ), + expand=True, + ), + expand=True, + ) + ) + + +ft.run(main) 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 index ee163ee9c..dc51c2c65 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py +++ b/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py @@ -47,6 +47,5 @@ class Image(Shape): 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. + A paint to composite the image into canvas. """ From 3642e023f519896cf3d049598e1d79f0fe34caf0 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 5 Aug 2025 20:19:52 -0700 Subject: [PATCH 14/16] Renamed example --- .../examples/controls/canvas/{strokes.py => dash_strokes.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename sdk/python/examples/controls/canvas/{strokes.py => dash_strokes.py} (100%) diff --git a/sdk/python/examples/controls/canvas/strokes.py b/sdk/python/examples/controls/canvas/dash_strokes.py similarity index 100% rename from sdk/python/examples/controls/canvas/strokes.py rename to sdk/python/examples/controls/canvas/dash_strokes.py From 8663e3bf6334d069bc0fccb0518534809d8b911b Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 5 Aug 2025 20:40:34 -0700 Subject: [PATCH 15/16] Add KeyboardListener docs and improve canvas docstrings Added documentation files for KeyboardListener and related key event types. Updated mkdocs navigation to include new docs. Enhanced docstrings for canvas capture methods and removed redundant event handler argument descriptions in GestureDetector and KeyboardListener. --- .../flet/docs/controls/keyboardlistener.md | 1 + .../packages/flet/docs/types/keydownevent.md | 1 + .../flet/docs/types/keyrepeatevent.md | 1 + .../packages/flet/docs/types/keyupevent.md | 1 + sdk/python/packages/flet/mkdocs.yml | 4 +++ .../src/flet/controls/core/canvas/canvas.py | 29 ++++++++++++++++++- .../flet/controls/core/gesture_detector.py | 9 ------ .../flet/controls/core/keyboard_listener.py | 11 ------- 8 files changed, 36 insertions(+), 21 deletions(-) create mode 100644 sdk/python/packages/flet/docs/controls/keyboardlistener.md create mode 100644 sdk/python/packages/flet/docs/types/keydownevent.md create mode 100644 sdk/python/packages/flet/docs/types/keyrepeatevent.md create mode 100644 sdk/python/packages/flet/docs/types/keyupevent.md diff --git a/sdk/python/packages/flet/docs/controls/keyboardlistener.md b/sdk/python/packages/flet/docs/controls/keyboardlistener.md new file mode 100644 index 000000000..d2397144f --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/keyboardlistener.md @@ -0,0 +1 @@ +::: flet.KeyboardListener diff --git a/sdk/python/packages/flet/docs/types/keydownevent.md b/sdk/python/packages/flet/docs/types/keydownevent.md new file mode 100644 index 000000000..0194d469b --- /dev/null +++ b/sdk/python/packages/flet/docs/types/keydownevent.md @@ -0,0 +1 @@ +::: flet.KeyDownEvent diff --git a/sdk/python/packages/flet/docs/types/keyrepeatevent.md b/sdk/python/packages/flet/docs/types/keyrepeatevent.md new file mode 100644 index 000000000..69581cc58 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/keyrepeatevent.md @@ -0,0 +1 @@ +::: flet.KeyRepeatEvent diff --git a/sdk/python/packages/flet/docs/types/keyupevent.md b/sdk/python/packages/flet/docs/types/keyupevent.md new file mode 100644 index 000000000..43e8891db --- /dev/null +++ b/sdk/python/packages/flet/docs/types/keyupevent.md @@ -0,0 +1 @@ +::: flet.KeyUpEvent diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 9927fc226..8441b46cb 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -372,6 +372,7 @@ nav: - FletApp: controls/fletapp.md - GestureDetector: controls/gesturedetector.md - InteractiveViewer: controls/interactiveviewer.md + - KeyboardListener: controls/keyboardlistener.md - MergeSemantics: controls/mergesemantics.md - SelectionArea: controls/selectionarea.md - Semantics: controls/semantics.md @@ -604,6 +605,9 @@ nav: - FilePickerUploadEvent: types/filepickeruploadevent.md - HoverEvent: types/hoverevent.md - KeyboardEvent: types/keyboardevent.md + - KeyDownEvent: types/keydownevent.md + - KeyRepeatEvent: types/keyrepeatevent.md + - KeyUpEvent: types/keyupevent.md - LoginEvent: types/loginevent.md - LongPressEndEvent: types/longpressendevent.md - LongPressStartEvent: types/longpressstartevent.md 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 03de9551b..2206bf0d2 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 @@ -63,16 +63,43 @@ def before_update(self): self.height = float("inf") async def capture_async(self): + """ + Captures the current visual state of the canvas asynchronously. + + The captured image is stored internally and will be rendered as a background + beneath all subsequently drawn shapes. + """ await self._invoke_method_async("capture") def capture(self): + """ + Initiates an asynchronous capture of the current canvas state. + + This is a non-blocking version of `capture_async()` and should be used in synchronous contexts. + """ asyncio.create_task(self.capture_async()) - async def get_capture_async(self): + async def get_capture_async(self) -> bytes: + """ + Retrieves the most recent canvas capture as PNG bytes. + + Returns: + bytes: The captured image in PNG format, or an empty result if no capture has been made. + """ return await self._invoke_method_async("get_capture") async def clear_capture_async(self): + """ + Clears the previously captured canvas image asynchronously. + + After clearing, no background will be rendered from a prior capture. + """ await self._invoke_method_async("capture") def clear_capture(self): + """ + Initiates an asynchronous operation to clear the captured canvas image. + + This is a non-blocking version of `clear_capture_async()` and should be used in synchronous contexts. + """ asyncio.create_task(self.clear_capture_async()) 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 fc159226b..0142f5115 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 @@ -241,27 +241,18 @@ class GestureDetector(ConstrainedControl, AdaptiveControl): """ 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: Optional[EventHandler[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: Optional[EventHandler[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: Optional[EventHandler[ScaleStartEvent["GestureDetector"]]] = None 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 index 941744b99..ada50059c 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py +++ b/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py @@ -31,8 +31,6 @@ 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 @@ -54,25 +52,16 @@ class KeyboardListener(Control): on_key_down: Optional[EventHandler[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: Optional[EventHandler[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: Optional[EventHandler[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): From 8220e366cf55f6e5ba2ae5094caaad26477cc427 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 5 Aug 2025 21:15:04 -0700 Subject: [PATCH 16/16] Add keyboard listener example and improve docs Added a new example 'detect_keys.py' demonstrating keyboard event handling. Updated documentation to include the example and enhanced event class docstrings for clarity in 'keyboard_listener.py'. --- .../controls/keyboard_listener/detect_keys.py | 26 +++++++++++++ .../flet/docs/controls/keyboardlistener.md | 8 ++++ .../flet/controls/core/keyboard_listener.py | 37 +++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 sdk/python/examples/controls/keyboard_listener/detect_keys.py diff --git a/sdk/python/examples/controls/keyboard_listener/detect_keys.py b/sdk/python/examples/controls/keyboard_listener/detect_keys.py new file mode 100644 index 000000000..1a2655b15 --- /dev/null +++ b/sdk/python/examples/controls/keyboard_listener/detect_keys.py @@ -0,0 +1,26 @@ +import flet as ft + + +async def main(page: ft.Page): + pressed_keys = set() + + def key_down(e: ft.KeyDownEvent): + pressed_keys.add(e.key) + keys.controls = [ft.Text(k, size=20) for k in pressed_keys] + + def key_up(e: ft.KeyUpEvent): + pressed_keys.remove(e.key) + keys.controls = [ft.Text(k, size=20) for k in pressed_keys] + + page.add( + ft.Text("Press any keys..."), + ft.KeyboardListener( + content=(keys := ft.Row()), + autofocus=True, + on_key_down=key_down, + on_key_up=key_up, + ), + ) + + +ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/keyboardlistener.md b/sdk/python/packages/flet/docs/controls/keyboardlistener.md index d2397144f..2b1c7ebbb 100644 --- a/sdk/python/packages/flet/docs/controls/keyboardlistener.md +++ b/sdk/python/packages/flet/docs/controls/keyboardlistener.md @@ -1 +1,9 @@ +## Examples + +### Press any keys + +```python +--8<-- "../../examples/controls/keyboard_listener/detect_keys.py" +``` + ::: flet.KeyboardListener 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 index ada50059c..30908786f 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py +++ b/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py @@ -13,17 +13,54 @@ @dataclass class KeyDownEvent(Event["KeyboardListener"]): + """ + Event triggered when a key is pressed down. + + Typically used to detect the initial press of a key before it is released or repeated. + """ + key: str + """ + The key that was pressed down. + + Represents the physical key (e.g., A, Enter, + Shift) that triggered the key down event. + """ @dataclass class KeyUpEvent(Event["KeyboardListener"]): + """ + Event triggered when a key is released. + + Useful for tracking when a key is no longer being pressed after + a key down or repeat event. + """ + key: str + """ + The key that was released. + + Indicates which key was previously pressed and has now been lifted. + """ @dataclass class KeyRepeatEvent(Event["KeyboardListener"]): + """ + Event triggered when a key is held down and repeating. + + This event fires continuously while the key remains pressed, + depending on the system's key repeat rate. + """ + key: str + """ + The key that is being held down and repeating. + + Represents the physical key that is generating repeat events (e.g., + ArrowDown, Backspace). + """ @control("KeyboardListener")