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/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/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 5ce085f0b..b8ae84ec9 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'; @@ -29,13 +35,107 @@ 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; + + @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); + 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"); + if (_lastSize.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, + capturedImage: _capturedImage, + onPaintCallback: (_) {}, + dpr: _dpr); + + painter.paint(canvas, _lastSize); + + final picture = recorder.endRecording(); + _capturedImage = await picture.toImage( + (_lastSize.width * _dpr).toInt(), + (_lastSize.height * _dpr).toInt(), + ); + 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; + setState(() {}); + return; + + default: + throw Exception("Unknown Canvas method: $name"); + } + } @override 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( @@ -43,16 +143,16 @@ class _CanvasControlState extends State { context: context, theme: Theme.of(context), shapes: widget.control.children("shapes"), + capturedImage: _capturedImage, + dpr: 1, onPaintCallback: (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}); - } + var now = DateTime.now().millisecondsSinceEpoch; + if ((now - _lastResize > resizeInterval && _lastSize != size) || + _lastSize.isEmpty) { + _lastSize = size; + _lastResize = now; + widget.control + .triggerEvent("resize", {"w": size.width, "h": size.height}); } }, ), @@ -68,18 +168,35 @@ 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 double dpr; + + const FletCustomPainter({ + required this.context, + required this.theme, + required this.shapes, + required this.onPaintCallback, + required this.dpr, + this.capturedImage, + }); @override void paint(Canvas canvas, Size size) { onPaintCallback(size); - //debugPrint("SHAPE CONTROLS: $shapes"); + debugPrint("paint.size: $size"); + //debugPrint("paint.shapes: $shapes"); + + canvas.save(); + canvas.scale(dpr); + canvas.clipRect(Rect.fromLTWH(0, 0, size.width, size.height)); + + if (capturedImage != null) { + final src = Rect.fromLTWH(0, 0, capturedImage!.width.toDouble(), + capturedImage!.height.toDouble()); + final dst = Rect.fromLTWH(0, 0, size.width, size.height); + canvas.drawImageRect(capturedImage!, src, dst, Paint()); + } for (var shape in shapes) { shape.notifyParent = true; @@ -105,8 +222,12 @@ class FletCustomPainter extends CustomPainter { drawShadow(canvas, shape); } else if (shape.type == "Text") { drawText(context, canvas, shape); + } else if (shape.type == "Image") { + drawImage(canvas, shape); } } + + canvas.restore(); } @override @@ -260,6 +381,29 @@ class FletCustomPainter extends CustomPainter { canvas.drawShadow(path, color, elevation, transparentOccluder); } + void drawImage(Canvas canvas, Control shape) { + final paint = shape.getPaint("paint", theme, Paint())!; + final x = shape.getDouble("x")!; + final y = shape.getDouble("y")!; + final width = shape.getDouble("width"); + final height = shape.getDouble("height"); + + // Check if image is already loaded and stored + if (shape.get("_image") != null && + shape.get("_hash") == getImageHash(shape)) { + final img = shape.get("_image")!; + final srcRect = + Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()); + final dstRect = width != null && height != null + ? Rect.fromLTWH(x, y, width, height) + : Offset(x, y) & Size(img.width.toDouble(), img.height.toDouble()); + debugPrint("canvas.drawImageRect($srcRect, $dstRect)"); + canvas.drawImageRect(img, srcRect, dstRect, paint); + } else { + loadCanvasImage(shape); + } + } + ui.Path buildPath(dynamic j) { var path = ui.Path(); if (j == null) { @@ -330,3 +474,59 @@ 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?; + + 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); + 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/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/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/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/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/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/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-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/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/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/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()) 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/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 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. """ - 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/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/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, 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, }, )