diff --git a/.appveyor.yml b/.appveyor.yml index 03bc2b047..3124a4c84 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -173,7 +173,7 @@ for: test_script: - cd sdk/python - - uv run pytest -s -o log_cli=true -o log_cli_level=DEBUG packages/flet/integration_tests/ + - uv run pytest -s -o log_cli=true -o log_cli_level=INFO packages/flet/integration_tests on_failure: - find packages/flet/integration_tests -type f -name '*_actual.png' -exec appveyor PushArtifact {} \; 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/integration_test/app_test.dart b/client/integration_test/app_test.dart index aa7cd4f36..9659913c5 100644 --- a/client/integration_test/app_test.dart +++ b/client/integration_test/app_test.dart @@ -22,11 +22,23 @@ void main() { if (fletTestAppUrl != "") { args.add(fletTestAppUrl); } + + const fletTestPidFile = String.fromEnvironment("FLET_TEST_PID_FILE_PATH"); + if (fletTestPidFile != "") { + args.add(fletTestPidFile); + } + + const fletTestAssetsDir = String.fromEnvironment("FLET_TEST_ASSETS_DIR"); + if (fletTestAssetsDir != "") { + args.add(fletTestAssetsDir); + } + app.main(args); await Future.delayed(const Duration(milliseconds: 500)); await app.tester?.pump(duration: const Duration(seconds: 1)); - await app.tester?.pumpAndSettle(const Duration(milliseconds: 100)); + await app.tester + ?.pumpAndSettle(duration: const Duration(milliseconds: 100)); await app.tester?.waitForTeardown(); }); }); diff --git a/client/integration_test/flutter_tester.dart b/client/integration_test/flutter_tester.dart index 05792b96a..e5ee7ac36 100644 --- a/client/integration_test/flutter_tester.dart +++ b/client/integration_test/flutter_tester.dart @@ -18,11 +18,11 @@ class FlutterWidgetTester implements Tester { FlutterWidgetTester(this._tester, this._binding); @override - Future pumpAndSettle( - [Duration duration = const Duration(milliseconds: 100)]) async { + Future pumpAndSettle({Duration? duration}) async { await lock.acquire(); try { - await _tester.pumpAndSettle(duration); + await _tester + .pumpAndSettle(duration ?? const Duration(milliseconds: 100)); } finally { lock.release(); } diff --git a/client/lib/main.dart b/client/lib/main.dart index 91c89c947..86ce8dc56 100644 --- a/client/lib/main.dart +++ b/client/lib/main.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 264117383..f6019af25 100644 --- a/packages/flet/lib/src/controls/base_controls.dart +++ b/packages/flet/lib/src/controls/base_controls.dart @@ -81,21 +81,15 @@ Widget _directionality(Widget widget, Control control) { } Widget _expandable(Widget widget, Control control) { - var parent = control.parent; - if (parent != null && ["View", "Column", "Row"].contains(parent.type)) { - int? expand = control.properties.containsKey("expand") - ? control.get("expand") == true - ? 1 - : control.get("expand") == false - ? 0 - : control.getInt("expand") - : null; - var expandLoose = control.getBool("expand_loose", false)!; - return expand != null - ? (expandLoose == true) - ? Flexible(flex: expand, child: widget) - : Expanded(flex: expand, child: widget) - : widget; + int? expand = control.get("expand") == true + ? 1 + : control.get("expand") == false + ? 0 + : control.getInt("expand"); + if (expand != null && control.parent?.internals?["host_expanded"] == true) { + return (control.getBool("expand_loose") == true) + ? Flexible(flex: expand, child: widget) + : Expanded(flex: expand, child: widget); } return widget; } @@ -206,8 +200,15 @@ Widget _positionedControl( var right = control.getDouble("right", null); var bottom = control.getDouble("bottom", null); + var errorControl = ErrorControl("Error displaying ${control.type}", + description: + "Control can be positioned absolutely with \"left\", \"top\", \"right\" and \"bottom\" properties inside Stack control only and page.overlay."); + var animation = control.getAnimation("animate_position"); if (animation != null) { + if (control.parent?.internals?["host_positioned"] != true) { + return errorControl; + } if (left == null && top == null && right == null && bottom == null) { left = 0; top = 0; @@ -228,11 +229,8 @@ Widget _positionedControl( child: widget, ); } else if (left != null || top != null || right != null || bottom != null) { - var parent = control.parent; - if (!["Stack", "Page", "Overlay"].contains(parent?.type)) { - return ErrorControl("Error displaying ${control.type}", - description: - "Control can be positioned absolutely with \"left\", \"top\", \"right\" and \"bottom\" properties inside Stack control only."); + if (control.parent?.internals?["host_positioned"] != true) { + return errorControl; } return Positioned( left: left, @@ -246,10 +244,16 @@ Widget _positionedControl( } Widget _sizedControl(Widget widget, Control control) { + final skipProps = control.internals?["skip_properties"] as List?; + if (skipProps?.contains("width") == true || + skipProps?.contains("height") == true) { + return widget; + } + var width = control.getDouble("width"); var height = control.getDouble("height"); - if ((width != null || height != null) && - !["container", "image"].contains(control.type)) { + + if ((width != null || height != null)) { 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 35818e62e..23b6bd269 100644 --- a/packages/flet/lib/src/controls/canvas.dart +++ b/packages/flet/lib/src/controls/canvas.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flet/src/extensions/control.dart'; @@ -7,9 +10,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 +34,115 @@ class CanvasControl extends StatefulWidget { class _CanvasControlState extends State { int _lastResize = DateTime.now().millisecondsSinceEpoch; - Size? _lastSize; + Size _lastSize = Size.zero; + ui.Image? _capturedImage; + Size _capturedSize = Size.zero; + 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; + } + + var dpr = parseDouble(args["pixel_ratio"]) ?? _dpr; + + // Wait for all images to load + await _awaitImageLoads(shapes); + + if (!mounted) return; + + 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); + + painter.paint(canvas, _lastSize); + + final picture = recorder.endRecording(); + _capturedImage = await picture.toImage( + (_lastSize.width * dpr).toInt(), + (_lastSize.height * dpr).toInt(), + ); + _capturedSize = _lastSize; + 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 +150,17 @@ class _CanvasControlState extends State { context: context, theme: Theme.of(context), shapes: widget.control.children("shapes"), + capturedImage: _capturedImage, + capturedSize: _capturedSize, + 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 +176,37 @@ class FletCustomPainter extends CustomPainter { final ThemeData theme; 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.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.scale(dpr); + 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; @@ -105,8 +232,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 @@ -118,47 +249,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) { @@ -185,20 +353,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) { @@ -210,7 +391,7 @@ class FletCustomPainter extends CustomPainter { style = style.copyWith(color: theme.textTheme.bodyMedium!.color); } TextSpan span = TextSpan( - text: shape.getString("text", "")!, + text: shape.getString("value", "")!, style: style, children: parseTextSpans(shape.children("spans"), theme)); @@ -260,6 +441,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 +534,57 @@ 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 { + 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 338bd2489..4fb3371f8 100644 --- a/packages/flet/lib/src/controls/gesture_detector.dart +++ b/packages/flet/lib/src/controls/gesture_detector.dart @@ -36,6 +36,9 @@ class _GestureDetectorControlState extends State { int _hoverTimestamp = DateTime.now().millisecondsSinceEpoch; Offset _localHover = Offset.zero; Timer? _debounce; + bool _rightPanActive = false; + int _rightPanTimestamp = DateTime.now().millisecondsSinceEpoch; + Offset _rightPanStart = Offset.zero; @override void initState() { @@ -362,16 +365,55 @@ 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; + _rightPanStart = event.localPosition; + 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(_rightPanStart)); + _rightPanStart = event.localPosition; + } + } + } + : 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 f36e808e0..4b6d6666f 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/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/controls/markdown.dart b/packages/flet/lib/src/controls/markdown.dart index b0de1d82e..d26cb3d57 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("image_error_content")); diff --git a/packages/flet/lib/src/flet_backend.dart b/packages/flet/lib/src/flet_backend.dart index 1296dad6c..84274135e 100644 --- a/packages/flet/lib/src/flet_backend.dart +++ b/packages/flet/lib/src/flet_backend.dart @@ -75,7 +75,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; @@ -344,7 +345,7 @@ class FletBackend extends ChangeNotifier { debugPrint("Page media updated: $newMedia"); media = newMedia; updateControl(page.id, {"media": newMedia.toMap()}); - triggerControlEvent(page, "media_change"); + triggerControlEvent(page, "media_change", newMedia.toMap()); notifyListeners(); } diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index b7c471adb..0b118c2ab 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'; @@ -246,6 +247,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/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/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, diff --git a/packages/flet/lib/src/services/tester.dart b/packages/flet/lib/src/services/tester.dart index 1bc89b762..1fcc64cb1 100644 --- a/packages/flet/lib/src/services/tester.dart +++ b/packages/flet/lib/src/services/tester.dart @@ -1,8 +1,6 @@ +import 'package:flet/flet.dart'; import 'package:flutter/material.dart'; -import '../flet_service.dart'; -import '../testing/test_finder.dart'; -import '../utils/icons.dart'; import '../utils/keys.dart'; class TesterService extends FletService { @@ -27,10 +25,12 @@ class TesterService extends FletService { debugPrint("Tester.$name($args)"); switch (name) { case "pump": - await control.backend.tester!.pump(duration: args["duration"]); + await control.backend.tester! + .pump(duration: parseDuration(args["duration"])); case "pump_and_settle": - await control.backend.tester!.pumpAndSettle(); + await control.backend.tester! + .pumpAndSettle(duration: parseDuration(args["duration"])); case "find_by_text": var finder = control.backend.tester!.findByText(args["text"]); diff --git a/packages/flet/lib/src/testing/tester.dart b/packages/flet/lib/src/testing/tester.dart index d870f2c6b..91b7bf082 100644 --- a/packages/flet/lib/src/testing/tester.dart +++ b/packages/flet/lib/src/testing/tester.dart @@ -5,7 +5,7 @@ import 'package:flutter/widgets.dart'; import 'test_finder.dart'; abstract class Tester { - Future pumpAndSettle([Duration duration]); + Future pumpAndSettle({Duration? duration}); Future pump({Duration? duration}); TestFinder findByText(String text); TestFinder findByTextContaining(String pattern); 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 d466dce9b..e2ae09b4c 100644 --- a/packages/flet/lib/src/utils/box.dart +++ b/packages/flet/lib/src/utils/box.dart @@ -166,11 +166,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, @@ -189,7 +188,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/examples/apps/todo/todo.py b/sdk/python/examples/apps/todo/todo.py index a545c1019..edae3ed20 100644 --- a/sdk/python/examples/apps/todo/todo.py +++ b/sdk/python/examples/apps/todo/todo.py @@ -8,7 +8,7 @@ def __init__(self, task_name, task_delete): self.task_name = task_name self.task_delete = task_delete - def init(self): + def build(self): self.display_task = ft.Checkbox( value=False, label=self.task_name, on_change=self.status_changed ) @@ -72,7 +72,7 @@ def delete_clicked(self, e): class TodoApp(ft.Column): # application's root control is a Column containing all other controls - def init(self): + def build(self): self.new_task = ft.TextField( hint_text="What needs to be done?", on_submit=self.add_clicked, expand=True ) diff --git a/sdk/python/examples/controls/canvas/bezier_curves.py b/sdk/python/examples/controls/canvas/bezier_curves.py index 896ff10b3..020c43fc9 100644 --- a/sdk/python/examples/controls/canvas/bezier_curves.py +++ b/sdk/python/examples/controls/canvas/bezier_curves.py @@ -10,13 +10,13 @@ def main(page: ft.Page): cv.Path( paint=ft.Paint(stroke_width=2, style=ft.PaintingStyle.STROKE), elements=[ - cv.Path.MoveTo(75, 25), - cv.Path.QuadraticTo(25, 25, 25, 62.5), - cv.Path.QuadraticTo(25, 100, 50, 100), - cv.Path.QuadraticTo(50, 120, 30, 125), - cv.Path.QuadraticTo(60, 120, 65, 100), - cv.Path.QuadraticTo(125, 100, 125, 62.5), - cv.Path.QuadraticTo(125, 25, 75, 25), + cv.Path.MoveTo(x=75, y=25), + cv.Path.QuadraticTo(cp1x=25, cp1y=25, x=25, y=62.5), + cv.Path.QuadraticTo(cp1x=25, cp1y=100, x=50, y=100), + cv.Path.QuadraticTo(cp1x=50, cp1y=120, x=30, y=125), + cv.Path.QuadraticTo(cp1x=60, cp1y=120, x=65, y=100), + cv.Path.QuadraticTo(cp1x=125, cp1y=100, x=125, y=62.5), + cv.Path.QuadraticTo(cp1x=125, cp1y=25, x=75, y=25), ], ), cv.Path( @@ -25,13 +25,25 @@ def main(page: ft.Page): x=100, y=100, elements=[ - cv.Path.MoveTo(75, 40), - cv.Path.CubicTo(75, 37, 70, 25, 50, 25), - cv.Path.CubicTo(20, 25, 20, 62.5, 20, 62.5), - cv.Path.CubicTo(20, 80, 40, 102, 75, 120), - cv.Path.CubicTo(110, 102, 130, 80, 130, 62.5), - cv.Path.CubicTo(130, 62.5, 130, 25, 100, 25), - cv.Path.CubicTo(85, 25, 75, 37, 75, 40), + cv.Path.MoveTo(x=75, y=40), + cv.Path.CubicTo( + cp1x=75, cp1y=37, cp2x=70, cp2y=25, x=50, y=25 + ), + cv.Path.CubicTo( + cp1x=20, cp1y=25, cp2x=20, cp2y=62.5, x=20, y=62.5 + ), + cv.Path.CubicTo( + cp1x=20, cp1y=80, cp2x=40, cp2y=102, x=75, y=120 + ), + cv.Path.CubicTo( + cp1x=110, cp1y=102, cp2x=130, cp2y=80, x=130, y=62.5 + ), + cv.Path.CubicTo( + cp1x=130, cp1y=62.5, cp2x=130, cp2y=25, x=100, y=25 + ), + cv.Path.CubicTo( + cp1x=85, cp1y=25, cp2x=75, cp2y=37, x=75, y=40 + ), ], ) ], diff --git a/sdk/python/examples/controls/canvas/brush.py b/sdk/python/examples/controls/canvas/brush.py index 0b2261364..f3780ac29 100644 --- a/sdk/python/examples/controls/canvas/brush.py +++ b/sdk/python/examples/controls/canvas/brush.py @@ -1,10 +1,16 @@ +from dataclasses import dataclass + import flet as ft import flet.canvas as cv +MAX_SHAPES_PER_CAPTURE = 30 + +@dataclass class State: - x: float - y: float + x: float = 0 + y: float = 0 + shapes_count: int = 1 state = State() @@ -13,36 +19,40 @@ class State: def main(page: ft.Page): page.title = "Canvas Example" + file_picker = ft.FilePicker() + page.services.append(file_picker) + def handle_pan_start(e: ft.DragStartEvent): - state.x = e.local_x - state.y = e.local_y + state.x = e.local_position.x + state.y = e.local_position.y - def handle_pan_update(e: ft.DragUpdateEvent): + async def handle_pan_update(e: ft.DragUpdateEvent): + ft.UpdateBehavior.disable_auto_update() canvas.shapes.append( cv.Line( x1=state.x, y1=state.y, - x2=e.local_x, - y2=e.local_y, + x2=e.local_position.x, + y2=e.local_position.y, paint=ft.Paint(stroke_width=3), ) ) canvas.update() - state.x = e.local_x - state.y = e.local_y + state.shapes_count += 1 + + if state.shapes_count == MAX_SHAPES_PER_CAPTURE: + await canvas.capture_async() + canvas.shapes.clear() + canvas.update() + state.shapes_count = 0 + + state.x = e.local_position.x + state.y = e.local_position.y canvas = cv.Canvas( expand=False, shapes=[ - cv.Fill( - ft.Paint( - gradient=ft.PaintLinearGradient( - begin=(0, 0), - end=(600, 600), - colors=[ft.Colors.CYAN_50, ft.Colors.GREY], - ) - ) - ), + cv.Fill(ft.Paint(color=ft.Colors.WHITE)), ], content=ft.GestureDetector( on_pan_start=handle_pan_start, @@ -51,13 +61,31 @@ def handle_pan_update(e: ft.DragUpdateEvent): ), ) + async def save_image(): + await canvas.capture_async() + capture = await canvas.get_capture_async() + if capture: + file_path = await file_picker.save_file_async( + file_name="flet_picture.png", src_bytes=capture + ) + if file_path and page.platform in [ + ft.PagePlatform.MACOS, + ft.PagePlatform.WINDOWS, + ft.PagePlatform.LINUX, + ]: + with open(file_path, "wb") as f: + f.write(capture) + page.add( + ft.Button("Save image", on_click=save_image), ft.Container( content=canvas, border_radius=5, + border=ft.Border.all(2), + bgcolor=ft.Colors.WHITE, width=float("inf"), expand=True, - ) + ), ) diff --git a/sdk/python/examples/controls/canvas/brush_on_image.py b/sdk/python/examples/controls/canvas/brush_on_image.py index 22d53c43f..0ffc3f80f 100644 --- a/sdk/python/examples/controls/canvas/brush_on_image.py +++ b/sdk/python/examples/controls/canvas/brush_on_image.py @@ -14,21 +14,28 @@ def main(page: ft.Page): page.title = "Flet Brush" def handle_pan_start(e: ft.DragStartEvent): - state.x = e.local_x - state.y = e.local_y + state.x = e.local_position.x + state.y = e.local_position.y def handle_pan_update(e: ft.DragUpdateEvent): canvas.shapes.append( cv.Line( - state.x, state.y, e.local_x, e.local_y, paint=ft.Paint(stroke_width=3) + x1=state.x, + y1=state.y, + x2=e.local_position.x, + y2=e.local_position.y, + paint=ft.Paint(stroke_width=3), ) ) canvas.update() - state.x = e.local_x - state.y = e.local_y + state.x = e.local_position.x + state.y = e.local_position.y page.add( ft.Container( + border_radius=5, + width=float("inf"), + expand=True, content=ft.Stack( controls=[ ft.Image( @@ -46,9 +53,6 @@ def handle_pan_update(e: ft.DragUpdateEvent): ), ] ), - border_radius=5, - width=float("inf"), - expand=True, ) ) diff --git a/sdk/python/examples/controls/canvas/dash_strokes.py b/sdk/python/examples/controls/canvas/dash_strokes.py new file mode 100644 index 000000000..478d5a304 --- /dev/null +++ b/sdk/python/examples/controls/canvas/dash_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( + expand=True, + content=ft.Column( + controls=[ + ft.Button("Toggle strokes", on_click=state.toggle_strokes), + fc.Canvas( + width=300, + height=300, + shapes=[ + fc.Line( + x1=30, + y1=30, + x2=200, + y2=100, + paint=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( + x=150, + y=150, + radius=130, + paint=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( + x=10, + y=10, + width=240, + height=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( + x=20, + y=20, + width=220, + height=220, + start_angle=0, + sweep_angle=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( + x=40, + y=60, + width=60, + height=70, + border_radius=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( + x=50, + y=50, + width=170, + height=140, + start_angle=math.pi * 0.1, + sweep_angle=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, + ), + ], + ), + ] + ), + ), + expand=True, + ) + ) + + +ft.run(main) diff --git a/sdk/python/examples/controls/canvas/flet_logo.py b/sdk/python/examples/controls/canvas/flet_logo.py index 98099ab8b..6d96d2202 100644 --- a/sdk/python/examples/controls/canvas/flet_logo.py +++ b/sdk/python/examples/controls/canvas/flet_logo.py @@ -12,10 +12,10 @@ def main(page: ft.Page): shapes=[ cv.Path( elements=[ - cv.Path.MoveTo(25, 125), - cv.Path.QuadraticTo(50, 25, 135, 35, 0.35), - cv.Path.QuadraticTo(75, 115, 135, 215, 0.6), - cv.Path.QuadraticTo(50, 225, 25, 125, 0.35), + cv.Path.MoveTo(x=25, y=125), + cv.Path.QuadraticTo(cp1x=50, cp1y=25, x=135, y=35, w=0.35), + cv.Path.QuadraticTo(cp1x=75, cp1y=115, x=135, y=215, w=0.6), + cv.Path.QuadraticTo(cp1x=50, cp1y=225, x=25, y=125, w=0.35), ], paint=ft.Paint( stroke_width=2, @@ -25,10 +25,10 @@ def main(page: ft.Page): ), cv.Path( elements=[ - cv.Path.MoveTo(85, 125), - cv.Path.QuadraticTo(120, 85, 165, 75, 0.5), - cv.Path.QuadraticTo(120, 115, 165, 175, 0.3), - cv.Path.QuadraticTo(120, 165, 85, 125, 0.5), + cv.Path.MoveTo(x=85, y=125), + cv.Path.QuadraticTo(cp1x=120, cp1y=85, x=165, y=75, w=0.5), + cv.Path.QuadraticTo(cp1x=120, cp1y=115, x=165, y=175, w=0.3), + cv.Path.QuadraticTo(cp1x=120, cp1y=165, x=85, y=125, w=0.5), ], paint=ft.Paint( stroke_width=2, diff --git a/sdk/python/examples/controls/canvas/media/brush.gif b/sdk/python/examples/controls/canvas/media/brush.gif deleted file mode 100644 index 7c3af11ae..000000000 Binary files a/sdk/python/examples/controls/canvas/media/brush.gif and /dev/null differ diff --git a/sdk/python/examples/controls/canvas/smiling_face.py b/sdk/python/examples/controls/canvas/smiling_face.py index a1f550171..68361350c 100644 --- a/sdk/python/examples/controls/canvas/smiling_face.py +++ b/sdk/python/examples/controls/canvas/smiling_face.py @@ -13,12 +13,20 @@ def main(page: ft.Page): width=float("inf"), expand=True, shapes=[ - cv.Circle(100, 100, 50, stroke_paint), - cv.Circle(80, 90, 10, stroke_paint), - cv.Circle(84, 87, 5, fill_paint), - cv.Circle(120, 90, 10, stroke_paint), - cv.Circle(124, 87, 5, fill_paint), - cv.Arc(70, 95, 60, 40, 0, math.pi, paint=stroke_paint), + cv.Circle(x=100, y=100, radius=50, paint=stroke_paint), + cv.Circle(x=80, y=90, radius=10, paint=stroke_paint), + cv.Circle(x=84, y=87, radius=5, paint=fill_paint), + cv.Circle(x=120, y=90, radius=10, paint=stroke_paint), + cv.Circle(x=124, y=87, radius=5, paint=fill_paint), + cv.Arc( + x=70, + y=95, + width=60, + height=40, + start_angle=0, + sweep_angle=math.pi, + paint=stroke_paint, + ), ], ) ) diff --git a/sdk/python/examples/controls/canvas/triangles.py b/sdk/python/examples/controls/canvas/triangles.py index f5c5c5ad3..bb3c0d109 100644 --- a/sdk/python/examples/controls/canvas/triangles.py +++ b/sdk/python/examples/controls/canvas/triangles.py @@ -11,17 +11,17 @@ def main(page: ft.Page): cv.Path( paint=ft.Paint(style=ft.PaintingStyle.FILL), elements=[ - cv.Path.MoveTo(25, 25), - cv.Path.LineTo(105, 25), - cv.Path.LineTo(25, 105), + cv.Path.MoveTo(x=25, y=25), + cv.Path.LineTo(x=105, y=25), + cv.Path.LineTo(x=25, y=105), ], ), cv.Path( paint=ft.Paint(stroke_width=2, style=ft.PaintingStyle.STROKE), elements=[ - cv.Path.MoveTo(125, 125), - cv.Path.LineTo(125, 45), - cv.Path.LineTo(45, 125), + cv.Path.MoveTo(x=125, y=125), + cv.Path.LineTo(x=125, y=45), + cv.Path.LineTo(x=45, y=125), cv.Path.Close(), ], ), 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/examples/cookbook/user-control-with-auto-update.py b/sdk/python/examples/cookbook/user-control-with-auto-update.py index 2319081b0..1cea40f98 100644 --- a/sdk/python/examples/cookbook/user-control-with-auto-update.py +++ b/sdk/python/examples/cookbook/user-control-with-auto-update.py @@ -8,7 +8,7 @@ class MyPanel(ft.Container): greeting: str = "Hi" # called only once - def init(self): + def build(self): print(self.page.platform) self.content = ft.Column( [ diff --git a/sdk/python/examples/tutorials/todo/todo/todo.py b/sdk/python/examples/tutorials/todo/todo/todo.py index a545c1019..edae3ed20 100644 --- a/sdk/python/examples/tutorials/todo/todo/todo.py +++ b/sdk/python/examples/tutorials/todo/todo/todo.py @@ -8,7 +8,7 @@ def __init__(self, task_name, task_delete): self.task_name = task_name self.task_delete = task_delete - def init(self): + def build(self): self.display_task = ft.Checkbox( value=False, label=self.task_name, on_change=self.status_changed ) @@ -72,7 +72,7 @@ def delete_clicked(self, e): class TodoApp(ft.Column): # application's root control is a Column containing all other controls - def init(self): + def build(self): self.new_task = ft.TextField( hint_text="What needs to be done?", on_submit=self.add_clicked, expand=True ) 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 9d1012c50..f10dc9edf 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,8 +11,8 @@ import msgpack from fastapi import WebSocket, WebSocketDisconnect from flet.controls.base_control import BaseControl +from flet.controls.context import _context_page from flet.controls.exceptions import FletPageDisconnectedException -from flet.controls.page import _session_page from flet.controls.update_behavior import UpdateBehavior from flet.messaging.connection import Connection from flet.messaging.protocol import ( @@ -139,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-web/src/flet_web/fastapi/flet_fastapi.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_fastapi.py index 497a13327..f833e72a2 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_fastapi.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_fastapi.py @@ -1,20 +1,17 @@ import asyncio -from contextlib import asynccontextmanager +from collections.abc import Awaitable, Coroutine, Sequence +from contextlib import asynccontextmanager, suppress from typing import ( Any, - Awaitable, Callable, - Coroutine, Dict, List, Optional, - Sequence, Type, Union, ) import fastapi -import flet_web.fastapi from fastapi.datastructures import Default from fastapi.params import Depends from fastapi.utils import generate_unique_id @@ -23,6 +20,8 @@ from starlette.responses import JSONResponse, Response from starlette.routing import BaseRoute +import flet_web.fastapi + class FastAPI(fastapi.FastAPI): def __init__( @@ -80,7 +79,8 @@ async def lifespan(app: fastapi.FastAPI): else: h() - yield + with suppress(asyncio.CancelledError): + yield if on_shutdown: for h in on_shutdown: if asyncio.iscoroutinefunction(h): diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/serve_fastapi_web_app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/serve_fastapi_web_app.py index 8220054af..1057373f4 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/serve_fastapi_web_app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/serve_fastapi_web_app.py @@ -12,9 +12,12 @@ class WebServerHandle: - def __init__(self, page_url: str, server: uvicorn.Server) -> None: + def __init__( + self, page_url: str, server: uvicorn.Server, serve_task: asyncio.Task + ) -> None: self.page_url = page_url self.server = server + self.serve_task = serve_task async def close(self): logger.info("Closing Flet web server...") @@ -61,10 +64,9 @@ async def serve_fastapi_web_app( web_renderer: WebRenderer = WebRenderer.AUTO, route_url_strategy: RouteUrlStrategy = RouteUrlStrategy.PATH, no_cdn: bool = False, - blocking: bool = False, on_startup: Optional[Any] = None, log_level: Optional[Union[str, int]] = None, -): +) -> WebServerHandle: web_path = f"/{page_name.strip('/')}" page_url = f"http://{url_host}:{port}{web_path if web_path != '/' else ''}" @@ -91,9 +93,6 @@ def startup(): ) server = uvicorn.Server(config) - if blocking: - await server.serve() - else: - asyncio.create_task(server.serve()) - - return WebServerHandle(page_url=page_url, server=server) + return WebServerHandle( + page_url=page_url, server=server, serve_task=asyncio.create_task(server.serve()) + ) diff --git a/sdk/python/packages/flet/docs/controls/canvas/image.md b/sdk/python/packages/flet/docs/controls/canvas/image.md new file mode 100644 index 000000000..7585cfcb3 --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/canvas/image.md @@ -0,0 +1,5 @@ +### Examples + +See [these](index.md#examples). + +::: flet.canvas.Image diff --git a/sdk/python/packages/flet/docs/controls/canvas/index.md b/sdk/python/packages/flet/docs/controls/canvas/index.md index cc5d67cef..1e806bbd6 100644 --- a/sdk/python/packages/flet/docs/controls/canvas/index.md +++ b/sdk/python/packages/flet/docs/controls/canvas/index.md @@ -52,14 +52,16 @@ /// caption /// -### Free-hand drawing with brush +### Free-hand drawing with image capture ```python --8<-- "../../examples/controls/canvas/brush.py" ``` -![brush](../../examples/controls/canvas/media/brush.gif){width="80%"} -/// caption -/// +### Gradients + +```python +--8<-- "../../examples/controls/canvas/gradients.py" +``` ::: flet.canvas.Canvas 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..2b1c7ebbb --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/keyboardlistener.md @@ -0,0 +1,9 @@ +## Examples + +### Press any keys + +```python +--8<-- "../../examples/controls/keyboard_listener/detect_keys.py" +``` + +::: 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/integration_tests/README.md b/sdk/python/packages/flet/integration_tests/README.md index c12bb6e3b..374fc3abd 100644 --- a/sdk/python/packages/flet/integration_tests/README.md +++ b/sdk/python/packages/flet/integration_tests/README.md @@ -46,4 +46,12 @@ Environment variables: `FLET_TEST_SCREENSHOTS_PIXEL_RATIO` - device pixel ration to use to take screenshots. Default is 2.0. -`FLET_TEST_SIMILARITY_THRESHOLD` - a minimum value for comparison result of golden and actual screenshot for a test to pass. Default is 99.0. \ No newline at end of file +`FLET_TEST_SIMILARITY_THRESHOLD` - a minimum value for comparison result of golden and actual screenshot for a test to pass. Default is 99.0. + +`FLET_TEST_USE_HTTP` - run Flet app in a web server. By default, the app starts socket +server, but if integration tests use assets they could be inaccessible via TCP from iOS or +Android device or simulator. + +`FLET_TEST_PID_FILE_PATH` - path to a Flutter client PID file. + +`FLET_TEST_ASSETS_DIR` - path to assets directory. \ No newline at end of file diff --git a/sdk/python/packages/flet/integration_tests/apps/finders.py b/sdk/python/packages/flet/integration_tests/apps/finders.py index 5b0f00514..edcd78efd 100644 --- a/sdk/python/packages/flet/integration_tests/apps/finders.py +++ b/sdk/python/packages/flet/integration_tests/apps/finders.py @@ -3,7 +3,7 @@ async def main(page: ft.Page): print("Test mode:", page.test) - page.window.width = 400 + page.window.width = 800 page.add(ft.Text("Hello, world!")) page.add(ft.Button("Button_1")) page.add(ft.Button("Button_2")) diff --git a/sdk/python/packages/flet/integration_tests/assets/141-50x50.jpg b/sdk/python/packages/flet/integration_tests/assets/141-50x50.jpg new file mode 100644 index 000000000..2fcb73ebe Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/assets/141-50x50.jpg differ diff --git a/sdk/python/packages/flet/integration_tests/assets/52-100x100.png b/sdk/python/packages/flet/integration_tests/assets/52-100x100.png new file mode 100644 index 000000000..0cee0b6ab Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/assets/52-100x100.png differ diff --git a/sdk/python/packages/flet/integration_tests/conftest.py b/sdk/python/packages/flet/integration_tests/conftest.py new file mode 100644 index 000000000..c731aa62e --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/conftest.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import flet.testing as ftt +import pytest_asyncio + + +@pytest_asyncio.fixture(scope="module") +async def flet_app(request): + params = getattr(request, "param", {}) + flet_app = ftt.FletTestApp( + flutter_app_dir=(Path(__file__).parent / "../../../../../client").resolve(), + test_path=request.fspath, + flet_app_main=params.get("flet_app_main"), + assets_dir=Path(__file__).resolve().parent / "assets", + ) + await flet_app.start() + yield flet_app + await flet_app.teardown() diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_1.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_1.png new file mode 100644 index 000000000..d5cddf796 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_1.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_2.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_2.png new file mode 100644 index 000000000..f9b85885b Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_2.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_3.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_3.png new file mode 100644 index 000000000..82191fb3d Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/capture_3.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_arc.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_arc.png new file mode 100644 index 000000000..0f6f674cb Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_arc.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_asset_image.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_asset_image.png new file mode 100644 index 000000000..0a7afc402 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_asset_image.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_circle.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_circle.png new file mode 100644 index 000000000..05e8034b6 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_circle.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_arc.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_arc.png new file mode 100644 index 000000000..ef34d2a61 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_arc.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_arc_with_center.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_arc_with_center.png new file mode 100644 index 000000000..3b983c0eb Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_arc_with_center.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_circle.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_circle.png new file mode 100644 index 000000000..8c8f848be Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_circle.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_line.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_line.png new file mode 100644 index 000000000..42f6a9aa3 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_line.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_oval.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_oval.png new file mode 100644 index 000000000..58f962a06 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_oval.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_path_with_fill.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_path_with_fill.png new file mode 100644 index 000000000..87a426219 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_path_with_fill.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_rect.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_rect.png new file mode 100644 index 000000000..879b063bf Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_dashed_rect.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_filled_circle_default_paint.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_filled_circle_default_paint.png new file mode 100644 index 000000000..793feb469 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_filled_circle_default_paint.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_filled_rect.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_filled_rect.png new file mode 100644 index 000000000..cf5075b4c Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_filled_rect.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_flet_logo_with_path.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_flet_logo_with_path.png new file mode 100644 index 000000000..534e79506 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_flet_logo_with_path.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_gradients.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_gradients.png new file mode 100644 index 000000000..fc963ad5d Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_gradients.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_line.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_line.png new file mode 100644 index 000000000..82191fb3d Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_line.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_oval.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_oval.png new file mode 100644 index 000000000..b7e633bd0 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_oval.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_text.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_text.png new file mode 100644 index 000000000..ed9318b37 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_text.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_url_image.png b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_url_image.png new file mode 100644 index 000000000..149568923 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/controls/golden/macos/canvas/draw_url_image.png differ diff --git a/sdk/python/packages/flet/integration_tests/controls/test_buttons.py b/sdk/python/packages/flet/integration_tests/controls/test_buttons.py index 00687d872..def46fa16 100644 --- a/sdk/python/packages/flet/integration_tests/controls/test_buttons.py +++ b/sdk/python/packages/flet/integration_tests/controls/test_buttons.py @@ -1,20 +1,6 @@ -from pathlib import Path - import flet as ft import flet.testing as ftt import pytest -import pytest_asyncio - - -@pytest_asyncio.fixture(scope="module") -async def flet_app(request): - flet_app = ftt.FletTestApp( - flutter_app_dir=(Path(__file__).parent / "../../../../../../client").resolve(), - test_path=request.fspath, - ) - await flet_app.start() - yield flet_app - await flet_app.teardown() @pytest.mark.asyncio(loop_scope="module") diff --git a/sdk/python/packages/flet/integration_tests/controls/test_canvas.py b/sdk/python/packages/flet/integration_tests/controls/test_canvas.py new file mode 100644 index 000000000..7f8a03338 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/controls/test_canvas.py @@ -0,0 +1,640 @@ +import math + +import flet as ft +import flet.canvas as fc +import flet.testing as ftt +import pytest + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_line(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [fc.Line(10, 10, 90, 90, ft.Paint(stroke_width=3))], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_line(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Line( + 10, + 10, + 90, + 90, + ft.Paint( + stroke_width=3, stroke_dash_pattern=[5, 5], color=ft.Colors.RED + ), + ) + ], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_circle(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Circle( + x=50, + y=50, + radius=40, + paint=ft.Paint(stroke_width=3, style=ft.PaintingStyle.STROKE), + ) + ], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_circle(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Circle( + x=50, + y=50, + radius=40, + paint=ft.Paint( + stroke_width=3, + stroke_dash_pattern=[5, 15], + color=ft.Colors.GREEN, + style=ft.PaintingStyle.STROKE, + ), + ) + ], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_filled_circle_default_paint(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [fc.Circle(x=50, y=50, radius=40, paint=ft.Paint())], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_oval(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Oval( + x=10, + y=10, + width=90, + height=40, + paint=ft.Paint(stroke_width=2, style=ft.PaintingStyle.STROKE), + ) + ], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_oval(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Oval( + x=10, + y=10, + width=90, + height=40, + paint=ft.Paint( + stroke_width=2, + stroke_dash_pattern=[5, 15], + color=ft.Colors.GREEN, + style=ft.PaintingStyle.STROKE, + ), + ) + ], + width=100, + height=100, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_arc(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Arc( + 40, + 40, + 100, + 60, + math.pi * 0.1, + math.pi * 0.4, + paint=ft.Paint( + color=ft.Colors.YELLOW, + stroke_width=4, + style=ft.PaintingStyle.STROKE, + ), + ) + ], + width=200, + height=150, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_arc(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Arc( + 40, + 40, + 100, + 60, + math.pi * 0.1, + math.pi * 0.4, + paint=ft.Paint( + color=ft.Colors.AMBER, + stroke_width=4, + stroke_dash_pattern=[7, 7], + style=ft.PaintingStyle.STROKE, + ), + use_center=False, + ) + ], + width=200, + height=150, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_arc_with_center(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Arc( + x=40, + y=40, + width=100, + height=60, + start_angle=math.pi * 0.1, + sweep_angle=math.pi * 0.6, + paint=ft.Paint( + color=ft.Colors.AMBER, + stroke_width=4, + stroke_dash_pattern=[7, 7], + style=ft.PaintingStyle.STROKE, + ), + use_center=True, + ) + ], + width=200, + height=150, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_filled_rect(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Rect( + x=40, + y=40, + width=100, + height=60, + border_radius=5, + paint=ft.Paint( + color=ft.Colors.AMBER, + stroke_width=4, + ), + ) + ], + width=200, + height=150, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_rect(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Rect( + x=40, + y=40, + width=100, + height=60, + border_radius=5, + paint=ft.Paint( + color=ft.Colors.BLUE, + stroke_width=4, + stroke_dash_pattern=[3, 3], + style=ft.PaintingStyle.STROKE, + ), + ) + ], + width=200, + height=150, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_flet_logo_with_path(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Path( + elements=[ + fc.Path.MoveTo(25, 125), + fc.Path.QuadraticTo(50, 25, 135, 35, 0.35), + fc.Path.QuadraticTo(75, 115, 135, 215, 0.6), + fc.Path.QuadraticTo(50, 225, 25, 125, 0.35), + ], + paint=ft.Paint( + stroke_width=2, + style=ft.PaintingStyle.FILL, + color=ft.Colors.PINK_400, + ), + ), + fc.Path( + elements=[ + fc.Path.MoveTo(85, 125), + fc.Path.QuadraticTo(120, 85, 165, 75, 0.5), + fc.Path.QuadraticTo(120, 115, 165, 175, 0.3), + fc.Path.QuadraticTo(120, 165, 85, 125, 0.5), + ], + paint=ft.Paint( + stroke_width=2, + style=ft.PaintingStyle.FILL, + color=ft.Colors.with_opacity(0.5, ft.Colors.BLUE_400), + ), + ), + ], + width=300, + height=300, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_dashed_path_with_fill(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Fill( + paint=ft.Paint( + style=ft.PaintingStyle.FILL, + gradient=ft.PaintLinearGradient( + begin=(0, 10), + end=(100, 50), + colors=[ft.Colors.BLUE, ft.Colors.YELLOW], + ), + ) + ), + fc.Path( + paint=ft.Paint( + stroke_width=3, + stroke_dash_pattern=[3, 3], + style=ft.PaintingStyle.STROKE, + ), + elements=[ + fc.Path.MoveTo(75, 25), + fc.Path.QuadraticTo(25, 25, 25, 62.5), + fc.Path.QuadraticTo(25, 100, 50, 100), + fc.Path.QuadraticTo(50, 120, 30, 125), + fc.Path.QuadraticTo(60, 120, 65, 100), + fc.Path.QuadraticTo(125, 100, 125, 62.5), + fc.Path.QuadraticTo(125, 25, 75, 25), + ], + ), + ], + width=150, + height=150, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_text(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Fill( + paint=ft.Paint( + style=ft.PaintingStyle.FILL, + color=ft.Colors.WHITE, + ) + ), + fc.Text( + x=0, + y=0, + value="Just a text", + ), + fc.Circle( + x=200, + y=100, + radius=2, + paint=ft.Paint(color=ft.Colors.RED), + ), + fc.Text( + x=200, + y=100, + style=ft.TextStyle(weight=ft.FontWeight.BOLD, size=30), + alignment=ft.Alignment.TOP_CENTER, + rotate=math.pi * 0.15, + value="Rotated", + spans=[ + ft.TextSpan( + text="around top_center", + style=ft.TextStyle( + italic=True, color=ft.Colors.GREEN, size=20 + ), + ) + ], + ), + fc.Circle( + x=400, + y=100, + radius=2, + paint=ft.Paint(color=ft.Colors.RED), + ), + fc.Text( + x=400, + y=100, + value="Rotated around top_left", + style=ft.TextStyle(size=20), + alignment=ft.Alignment.TOP_LEFT, + rotate=math.pi * -0.15, + ), + fc.Circle( + x=600, + y=200, + radius=2, + paint=ft.Paint(color=ft.Colors.RED), + ), + fc.Text( + x=600, + y=200, + value="Rotated around center", + style=ft.TextStyle(size=20), + alignment=ft.Alignment.CENTER, + rotate=math.pi / 2, + ), + fc.Text( + x=300, + y=400, + value="Limited to max_width and right-aligned.\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + text_align=ft.TextAlign.RIGHT, + max_width=400, + ), + fc.Text( + x=200, + y=200, + value="WOW!", + style=ft.TextStyle( + weight=ft.FontWeight.BOLD, + size=100, + foreground=ft.Paint( + color=ft.Colors.PINK, + stroke_width=6, + style=ft.PaintingStyle.STROKE, + stroke_join=ft.StrokeJoin.ROUND, + stroke_cap=ft.StrokeCap.ROUND, + ), + ), + ), + fc.Text( + x=200, + y=200, + value="WOW!", + style=ft.TextStyle( + weight=ft.FontWeight.BOLD, + size=100, + foreground=ft.Paint( + gradient=ft.PaintLinearGradient( + begin=(200, 200), + end=(300, 300), + colors=[ft.Colors.YELLOW, ft.Colors.RED], + ), + stroke_join=ft.StrokeJoin.ROUND, + stroke_cap=ft.StrokeCap.ROUND, + ), + ), + ), + ], + width=800, + height=500, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_gradients(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [ + fc.Rect( + x=10, + y=10, + width=100, + height=100, + border_radius=5, + paint=ft.Paint( + style=ft.PaintingStyle.FILL, + gradient=ft.PaintLinearGradient( + begin=(0, 10), + end=(100, 50), + colors=[ft.Colors.BLUE, ft.Colors.YELLOW], + ), + ), + ), + fc.Circle( + x=60, + y=170, + radius=50, + paint=ft.Paint( + style=ft.PaintingStyle.FILL, + gradient=ft.PaintRadialGradient( + radius=50, + center=(60, 170), + colors=[ft.Colors.YELLOW, ft.Colors.BLUE], + ), + ), + ), + fc.Path( + elements=[ + fc.Path.Arc( + x=10, + y=230, + width=100, + height=100, + start_angle=3 * math.pi / 4, + sweep_angle=3 * math.pi / 2, + ), + ], + paint=ft.Paint( + stroke_width=15, + stroke_join=ft.StrokeJoin.ROUND, + style=ft.PaintingStyle.STROKE, + gradient=ft.PaintSweepGradient( + start_angle=0, + end_angle=math.pi * 2, + rotation=3 * math.pi / 4, + center=(60, 280), + colors=[ft.Colors.YELLOW, ft.Colors.PURPLE], + color_stops=[0.0, 1.0], + ), + ), + ), + ], + width=150, + height=350, + ), + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_asset_image(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [fc.Image(src="52-100x100.png", x=10, y=10)], + width=120, + height=120, + ), + pump_times=1, + pump_duration=1000, + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_draw_url_image(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + await flet_app.assert_control_screenshot( + request.node.name, + fc.Canvas( + [fc.Image(src="https://picsum.photos/id/237/100/100", x=10, y=10)], + width=120, + height=120, + ), + pump_times=7, + pump_duration=1000, + ) + + +@pytest.mark.asyncio(loop_scope="module") +async def test_capture(flet_app: ftt.FletTestApp, request): + flet_app.page.theme_mode = ft.ThemeMode.LIGHT + canvas = fc.Canvas( + [ + fc.Circle( + x=50, + y=50, + radius=40, + paint=ft.Paint( + stroke_width=3, + color=ft.Colors.GREEN, + style=ft.PaintingStyle.STROKE, + ), + ) + ], + width=100, + height=100, + ) + screenshot = ft.Screenshot(canvas) + + # clean page + flet_app.page.clean() + await flet_app.tester.pump_and_settle() + + # add canvas to a page, pump and settle + flet_app.page.add(screenshot) + await flet_app.tester.pump_and_settle() + + # ensure there is no initial capture + capture_0 = await canvas.get_capture_async() + assert capture_0 is None + + # take capture and assert + await canvas.capture_async(pixel_ratio=flet_app.pixel_ratio) + capture_1 = await canvas.get_capture_async() + assert capture_1 is not None + flet_app.assert_screenshot("capture_1", capture_1) + + # clean canvas and draw a line + canvas.shapes = [fc.Line(10, 10, 90, 90, ft.Paint(stroke_width=3))] + canvas.update() + await flet_app.tester.pump_and_settle() + + # take screenshot + # it must be a circle striked out with a line (capture + shapes) + capture_2 = await screenshot.capture_async(pixel_ratio=flet_app.pixel_ratio) + flet_app.assert_screenshot("capture_2", capture_2) + + # clean current capture + await canvas.clear_capture_async() + await flet_app.tester.pump_and_settle() + + # take screenshot + # it must be just a single line + capture_3 = await screenshot.capture_async(pixel_ratio=flet_app.pixel_ratio) + flet_app.assert_screenshot("capture_3", capture_3) + + # back to empty capture + capture_4 = await canvas.get_capture_async() + assert capture_4 is None diff --git a/sdk/python/packages/flet/integration_tests/controls/test_text.py b/sdk/python/packages/flet/integration_tests/controls/test_text.py index 4e6b8f7cc..46a49db68 100644 --- a/sdk/python/packages/flet/integration_tests/controls/test_text.py +++ b/sdk/python/packages/flet/integration_tests/controls/test_text.py @@ -1,20 +1,6 @@ -from pathlib import Path - import flet as ft import flet.testing as ftt import pytest -import pytest_asyncio - - -@pytest_asyncio.fixture(scope="module") -async def flet_app(request): - flet_app = ftt.FletTestApp( - flutter_app_dir=(Path(__file__).parent / "../../../../../../client").resolve(), - test_path=request.fspath, - ) - await flet_app.start() - yield flet_app - await flet_app.teardown() @pytest.mark.asyncio(loop_scope="module") diff --git a/sdk/python/packages/flet/integration_tests/controls/test_textfield.py b/sdk/python/packages/flet/integration_tests/controls/test_textfield.py index 94f92e71f..0e94d3416 100644 --- a/sdk/python/packages/flet/integration_tests/controls/test_textfield.py +++ b/sdk/python/packages/flet/integration_tests/controls/test_textfield.py @@ -1,20 +1,6 @@ -from pathlib import Path - import flet as ft import flet.testing as ftt import pytest -import pytest_asyncio - - -@pytest_asyncio.fixture(scope="module") -async def flet_app(request): - flet_app = ftt.FletTestApp( - flutter_app_dir=(Path(__file__).parent / "../../../../../../client").resolve(), - test_path=request.fspath, - ) - await flet_app.start() - yield flet_app - await flet_app.teardown() @pytest.mark.asyncio(loop_scope="module") diff --git a/sdk/python/packages/flet/integration_tests/test_counter_app.py b/sdk/python/packages/flet/integration_tests/test_counter_app.py index 2e56ba1c4..0f4aaa4ff 100644 --- a/sdk/python/packages/flet/integration_tests/test_counter_app.py +++ b/sdk/python/packages/flet/integration_tests/test_counter_app.py @@ -1,42 +1,37 @@ -from pathlib import Path - import apps.counter as app import flet as ft import flet.testing as ftt import pytest -import pytest_asyncio - - -@pytest_asyncio.fixture(scope="module") -async def flet_app(request): - flet_app = ftt.FletTestApp( - flutter_app_dir=(Path(__file__).parent / "../../../../../client").resolve(), - flet_app_main=app.main, - test_path=request.fspath, - ) - await flet_app.start() - yield flet_app - await flet_app.teardown() -@pytest.mark.asyncio(loop_scope="module") -async def test_app(flet_app: ftt.FletTestApp): - tester = flet_app.tester - await tester.pump_and_settle() - zero_text = await tester.find_by_text("0") - assert zero_text.count == 1 +@pytest.mark.parametrize( + "flet_app", + [ + { + "flet_app_main": app.main, + } + ], + indirect=True, +) +class TestApp: + @pytest.mark.asyncio(loop_scope="module") + async def test_app(self, flet_app: ftt.FletTestApp): + tester = flet_app.tester + await tester.pump_and_settle() + zero_text = await tester.find_by_text("0") + assert zero_text.count == 1 - # tap increment button - increment_btn = await tester.find_by_icon(ft.Icons.ADD) - assert increment_btn.count == 1 - await tester.tap(increment_btn) - await tester.pump_and_settle() - assert (await tester.find_by_text("1")).count == 1 + # tap increment button + increment_btn = await tester.find_by_icon(ft.Icons.ADD) + assert increment_btn.count == 1 + await tester.tap(increment_btn) + await tester.pump_and_settle() + assert (await tester.find_by_text("1")).count == 1 - # tap decrement button - decrement_button = await tester.find_by_key("decrement") - assert decrement_button.count == 1 - await tester.tap(decrement_button) - await tester.tap(decrement_button) - await tester.pump_and_settle() - assert (await tester.find_by_text("-1")).count == 1 + # tap decrement button + decrement_button = await tester.find_by_key("decrement") + assert decrement_button.count == 1 + await tester.tap(decrement_button) + await tester.tap(decrement_button) + await tester.pump_and_settle() + assert (await tester.find_by_text("-1")).count == 1 diff --git a/sdk/python/packages/flet/integration_tests/test_finders.py b/sdk/python/packages/flet/integration_tests/test_finders.py index d4777d8d5..c6e26b7e6 100644 --- a/sdk/python/packages/flet/integration_tests/test_finders.py +++ b/sdk/python/packages/flet/integration_tests/test_finders.py @@ -1,62 +1,53 @@ -from pathlib import Path - import apps.finders as app import flet as ft import flet.testing as ftt import pytest -import pytest_asyncio - - -@pytest_asyncio.fixture(scope="module") -async def flet_app(request): - flet_app = ftt.FletTestApp( - flutter_app_dir=(Path(__file__).parent / "../../../../../client").resolve(), - flet_app_main=app.main, - test_path=request.fspath, - ) - await flet_app.start() - await flet_app.tester.pump_and_settle() - yield flet_app - await flet_app.teardown() - - -@pytest.mark.asyncio(loop_scope="module") -async def test_find_by_text(flet_app: ftt.FletTestApp): - finder = await flet_app.tester.find_by_text("Hello, world!") - assert finder.count == 2 - - -@pytest.mark.asyncio(loop_scope="module") -async def test_find_by_text_containing(flet_app: ftt.FletTestApp): - finder = await flet_app.tester.find_by_text_containing("Hello, world!") - assert finder.count == 3 - - finder = await flet_app.tester.find_by_text_containing("Hello") - assert finder.count == 4 - - finder = await flet_app.tester.find_by_text_containing("(\\s+)world") - assert finder.count == 3 - - finder = await flet_app.tester.find_by_text_containing("world!$") - assert finder.count == 3 - - -@pytest.mark.asyncio(loop_scope="module") -async def test_find_by_icon(flet_app: ftt.FletTestApp): - finder = await flet_app.tester.find_by_icon(ft.Icons.ADD_A_PHOTO) - assert finder.count == 1 - - -@pytest.mark.asyncio(loop_scope="module") -async def test_find_by_tooltip(flet_app: ftt.FletTestApp): - finder = await flet_app.tester.find_by_tooltip("Tooltip1") - assert finder.count == 1 - -@pytest.mark.asyncio(loop_scope="module") -async def test_find_by_key(flet_app: ftt.FletTestApp): - finder = await flet_app.tester.find_by_key("value_key_1") - assert finder.count == 1 - finder = await flet_app.tester.find_by_key(ft.ScrollKey("scroll_key_1")) - assert finder.count == 1 +@pytest.mark.parametrize( + "flet_app", + [ + { + "flet_app_main": app.main, + } + ], + indirect=True, +) +class TestFinders: + @pytest.mark.asyncio(loop_scope="module") + async def test_find_by_text(self, flet_app: ftt.FletTestApp): + await flet_app.tester.pump(1000) + finder = await flet_app.tester.find_by_text("Hello, world!") + assert finder.count == 2 + + @pytest.mark.asyncio(loop_scope="module") + async def test_find_by_text_containing(self, flet_app: ftt.FletTestApp): + finder = await flet_app.tester.find_by_text_containing("Hello, world!") + assert finder.count == 3 + + finder = await flet_app.tester.find_by_text_containing("Hello") + assert finder.count == 4 + + finder = await flet_app.tester.find_by_text_containing("(\\s+)world") + assert finder.count == 3 + + finder = await flet_app.tester.find_by_text_containing("world!$") + assert finder.count == 3 + + @pytest.mark.asyncio(loop_scope="module") + async def test_find_by_icon(self, flet_app: ftt.FletTestApp): + finder = await flet_app.tester.find_by_icon(ft.Icons.ADD_A_PHOTO) + assert finder.count == 1 + + @pytest.mark.asyncio(loop_scope="module") + async def test_find_by_tooltip(self, flet_app: ftt.FletTestApp): + finder = await flet_app.tester.find_by_tooltip("Tooltip1") + assert finder.count == 1 + + @pytest.mark.asyncio(loop_scope="module") + async def test_find_by_key(self, flet_app: ftt.FletTestApp): + finder = await flet_app.tester.find_by_key("value_key_1") + assert finder.count == 1 + + finder = await flet_app.tester.find_by_key(ft.ScrollKey("scroll_key_1")) + assert finder.count == 1 diff --git a/sdk/python/packages/flet/integration_tests/test_hello_world_app.py b/sdk/python/packages/flet/integration_tests/test_hello_world_app.py index a3e1932c9..60649bd9e 100644 --- a/sdk/python/packages/flet/integration_tests/test_hello_world_app.py +++ b/sdk/python/packages/flet/integration_tests/test_hello_world_app.py @@ -1,40 +1,33 @@ -from pathlib import Path - import apps.hello_world as app import flet.testing as ftt import pytest -import pytest_asyncio - - -@pytest_asyncio.fixture(scope="module") -async def flet_app(request): - flet_app = ftt.FletTestApp( - flutter_app_dir=(Path(__file__).parent / "../../../../../client").resolve(), - flet_app_main=app.main, - test_path=request.fspath, - ) - await flet_app.start() - yield flet_app - await flet_app.teardown() - - -@pytest.mark.asyncio(loop_scope="module") -async def test_app(flet_app: ftt.FletTestApp): - await flet_app.tester.pump_and_settle() - finder = await flet_app.tester.find_by_text("Hello, world!") - assert finder.count == 1 - # bytes = await flet_app.tester.take_screenshot("scr1") - # p = Path(get_current_script_dir(), "scr_1.png") - # print(p) - # with open(p, "wb") as f: - # f.write(bytes) - - -@pytest.mark.asyncio(loop_scope="module") -async def test_1(flet_app: ftt.FletTestApp): - print("Test 1") -@pytest.mark.asyncio(loop_scope="module") -async def test_2(flet_app: ftt.FletTestApp): - print("Test 2") +@pytest.mark.parametrize( + "flet_app", + [ + { + "flet_app_main": app.main, + } + ], + indirect=True, +) +class TestHelloWorld: + @pytest.mark.asyncio(loop_scope="module") + async def test_app(self, flet_app: ftt.FletTestApp): + await flet_app.tester.pump_and_settle() + finder = await flet_app.tester.find_by_text("Hello, world!") + assert finder.count == 1 + # bytes = await flet_app.tester.take_screenshot("scr1") + # p = Path(get_current_script_dir(), "scr_1.png") + # print(p) + # with open(p, "wb") as f: + # f.write(bytes) + + @pytest.mark.asyncio(loop_scope="module") + async def test_1(self, flet_app: ftt.FletTestApp): + print("Test 1") + + @pytest.mark.asyncio(loop_scope="module") + async def test_2(self, flet_app: ftt.FletTestApp): + print("Test 2") diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 094d29453..f73b21ee3 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -277,6 +277,7 @@ nav: - Circle: controls/canvas/circle.md - Color: controls/canvas/color.md - Fill: controls/canvas/fill.md + - Image: controls/canvas/image.md - Line: controls/canvas/line.md - Oval: controls/canvas/oval.md - Path: controls/canvas/path.md @@ -376,6 +377,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 @@ -611,6 +613,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/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 0ea87935b..ae8f1c657 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -49,6 +49,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 from flet.controls.control_builder import ControlBuilder from flet.controls.control_event import ( @@ -90,6 +91,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, @@ -342,7 +349,6 @@ Page, RouteChangeEvent, ViewPopEvent, - context, ) from flet.controls.page_view import PageMediaData, PageResizeEvent, PageView from flet.controls.painting import ( @@ -554,7 +560,6 @@ "CircularRectangleNotchShape", "ClipBehavior", "Clipboard", - "Clipboard", "ColorFilter", "ColorScheme", "ColorValue", @@ -633,7 +638,6 @@ "DropdownM2", "DropdownOption", "DropdownTheme", - "DropdownTheme", "Duration", "DurationValue", "ElevatedButton", @@ -682,9 +686,12 @@ "InputFilter", "InteractiveViewer", "Key", - "KeyValue", + "KeyDownEvent", + "KeyRepeatEvent", + "KeyUpEvent", "KeyValue", "KeyboardEvent", + "KeyboardListener", "KeyboardType", "LabelPosition", "LinearGradient", @@ -793,7 +800,6 @@ "ScaleUpdateEvent", "ScaleValue", "Screenshot", - "Screenshot", "ScrollDirection", "ScrollEvent", "ScrollKey", @@ -815,7 +821,6 @@ "ShakeDetector", "ShapeBorder", "SharedPreferences", - "SharedPreferences", "Size", "Slider", "SliderInteraction", @@ -840,7 +845,6 @@ "SystemOverlayStyle", "Tab", "TabAlignment", - "TabAlignment", "TabBar", "TabBarHoverEvent", "TabBarIndicatorSize", @@ -884,7 +888,6 @@ "UnderlineTabIndicator", "UpdateBehavior", "UrlLauncher", - "UrlLauncher", "UrlTarget", "ValueKey", "VerticalAlignment", diff --git a/sdk/python/packages/flet/src/flet/app.py b/sdk/python/packages/flet/src/flet/app.py index 27c008fd0..12b4fbf11 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 @@ -205,7 +206,6 @@ def exit_gracefully(signum, frame): web_renderer=web_renderer, route_url_strategy=route_url_strategy, no_cdn=no_cdn, - blocking=(view == AppView.WEB_BROWSER or view is None or force_web_server), on_startup=on_app_startup, ) ) @@ -240,7 +240,7 @@ def exit_gracefully(signum, frame): with contextlib.suppress(KeyboardInterrupt): await terminate.wait() - elif view is None: + elif view == AppView.WEB_BROWSER or view is None or force_web_server: with contextlib.suppress(KeyboardInterrupt): await terminate.wait() @@ -253,7 +253,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) @@ -315,7 +315,6 @@ async def __run_web_server( web_renderer: Optional[WebRenderer], route_url_strategy, no_cdn, - blocking, on_startup, ): ensure_flet_web_package_installed() @@ -344,7 +343,6 @@ async def __run_web_server( web_renderer=web_renderer, route_url_strategy=route_url_strategy, no_cdn=no_cdn, - blocking=blocking, on_startup=on_startup, log_level=logging.getLevelName(log_level).lower(), ) diff --git a/sdk/python/packages/flet/src/flet/canvas/__init__.py b/sdk/python/packages/flet/src/flet/canvas/__init__.py index 378478d29..32ddb5154 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 @@ -27,5 +28,6 @@ "Rect", "Shadow", "Text", + "Image", "Shape", ] 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 d0e683404..8736367c0 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.control_event import ControlEvent +from flet.controls.context import _context_page +from flet.controls.control_event import ControlEvent, get_event_field_type from flet.controls.control_id import ControlId from flet.controls.keys import KeyValue 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") @@ -127,6 +133,8 @@ def __post_init__(self, ref: Optional[Ref[Any]]): if ref is not None: ref.current = self + self.init() + # control_id = self._i # object_id = id(self) # ctrl_type = self._c @@ -170,6 +178,13 @@ def is_isolated(self): def init(self): pass + def build(self): + """ + Called once during control initialization to define its child controls. + self.page is available in this method. + """ + pass + def before_update(self): """ This method is called every time when this control is being updated. @@ -213,3 +228,76 @@ 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 = 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 to a page before triggering events. " + "Use page.add(control) or add it to a parent control that's on a page." + ) + 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 a78b1e6f8..3209a93d9 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 @@ -45,10 +46,80 @@ class Canvas(ConstrainedControl): """ Sampling interval in milliseconds for `on_resize` event. - Setting to `0` calls [`on_resize`][flet.canvas.Canvas.on_resize] immediately on every change. + Setting to `0` calls [`on_resize`][flet.canvas.Canvas.on_resize] immediately + on every change. """ on_resize: Optional[EventHandler[CanvasResizeEvent]] = None """ Called when the size of this canvas has changed. """ + + 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, pixel_ratio: Optional[Number] = None): + """ + 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. + + Args: + pixel_ratio: + The pixel density multiplier to use when rendering the capture. + `1.0` means 1 device pixel per logical pixel (no scaling). + Values greater than `1.0` produce higher-resolution captures. + If `None`, the device's default pixel ratio is used. + """ + await self._invoke_method_async( + "capture", arguments={"pixel_ratio": pixel_ratio} + ) + + def capture(self, pixel_ratio: Optional[Number] = None): + """ + 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. + + Args: + pixel_ratio: + The pixel density multiplier to use when rendering the capture. + `1.0` means 1 device pixel per logical pixel (no scaling). + Values greater than `1.0` produce higher-resolution captures. + If `None`, the device's default pixel ratio is used. + """ + asyncio.create_task(self.capture_async(pixel_ratio=pixel_ratio)) + + 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("clear_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/canvas/image.py b/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py new file mode 100644 index 000000000..dc51c2c65 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/core/canvas/image.py @@ -0,0 +1,51 @@ +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 Number + + +@control("Image") +class Image(Shape): + """ + Draws an image. + """ + + src: Optional[str] = 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: Optional[Number] = None + """ + The x-axis coordinate of the image's top-left corner. + """ + + y: Optional[Number] = None + """ + The y-axis coordinate of the image's top-left corner. + """ + + width: Optional[Number] = None + """ + The width of the rectangle to draw the image into. Use image width if None. + """ + + height: Optional[Number] = 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. + """ diff --git a/sdk/python/packages/flet/src/flet/controls/core/column.py b/sdk/python/packages/flet/src/flet/controls/core/column.py index be43dc396..ab906b1bb 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/column.py +++ b/sdk/python/packages/flet/src/flet/controls/core/column.py @@ -63,3 +63,7 @@ class Column(ConstrainedControl, ScrollableControl, AdaptiveControl): """ How the runs should be placed in the cross-axis when [`wrap`][flet.Column.wrap] is `True`. """ + + def init(self): + super().init() + self._internals["host_expanded"] = True 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 69e2184fb..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 @@ -12,6 +12,7 @@ HoverEvent, LongPressEndEvent, LongPressStartEvent, + PointerEvent, ScaleEndEvent, ScaleStartEvent, ScaleUpdateEvent, @@ -236,6 +237,24 @@ class GestureDetector(ConstrainedControl, AdaptiveControl): Called when a pointer is no longer in contact and was moving at a specific velocity. """ + on_right_pan_start: Optional[EventHandler[PointerEvent["GestureDetector"]]] = None + """ + Pointer has contacted the screen while secondary button pressed + and has begun to move. + """ + + 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. + """ + + 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. + """ + on_scale_start: Optional[EventHandler[ScaleStartEvent["GestureDetector"]]] = None """ Called when 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 26d15908e..0a5484965 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/image.py +++ b/sdk/python/packages/flet/src/flet/controls/core/image.py @@ -145,3 +145,7 @@ class Image(ConstrainedControl): Anti-aliasing alleviates the sawtooth artifact when the image is rotated. """ + + def init(self): + super().init() + self._internals["skip_properties"] = ["width", "height"] 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..30908786f --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/core/keyboard_listener.py @@ -0,0 +1,108 @@ +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, EventHandler + +__all__ = [ + "KeyboardListener", +] + + +@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") +class KeyboardListener(Control): + """ + A control that calls a callback whenever the user presses or releases + a key on a keyboard. + """ + + 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: Optional[EventHandler[KeyDownEvent]] = None + """ + Fires when a keyboard key is pressed. + """ + + on_key_up: Optional[EventHandler[KeyUpEvent]] = None + """ + Fires when a keyboard key is released. + """ + + on_key_repeat: Optional[EventHandler[KeyRepeatEvent]] = None + """ + Fires when a keyboard key is being hold, causing repeated events. + """ + + async def focus_async(self): + await self._invoke_method_async("focus") + + def focus(self): + asyncio.create_task(self.focus_async()) diff --git a/sdk/python/packages/flet/src/flet/controls/core/row.py b/sdk/python/packages/flet/src/flet/controls/core/row.py index 682360c2e..c12cfdd0b 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/row.py +++ b/sdk/python/packages/flet/src/flet/controls/core/row.py @@ -67,3 +67,7 @@ class Row(ConstrainedControl, ScrollableControl, AdaptiveControl): """ How the runs should be placed in the cross-axis when `wrap=True`. """ + + def init(self): + super().init() + self._internals["host_expanded"] = True diff --git a/sdk/python/packages/flet/src/flet/controls/core/screenshot.py b/sdk/python/packages/flet/src/flet/controls/core/screenshot.py index 52bfae427..2a1659d44 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/screenshot.py +++ b/sdk/python/packages/flet/src/flet/controls/core/screenshot.py @@ -2,7 +2,7 @@ from flet.controls.base_control import control from flet.controls.control import Control -from flet.controls.duration import Duration +from flet.controls.duration import DurationValue from flet.controls.types import Number __all__ = ["Screenshot"] @@ -20,7 +20,9 @@ class Screenshot(Control): """ async def capture_async( - self, pixel_ratio: Optional[Number] = None, delay: Optional[Duration] = None + self, + pixel_ratio: Optional[Number] = None, + delay: Optional[DurationValue] = None, ) -> bytes: """ Captures a screenshot of the enclosed content control. diff --git a/sdk/python/packages/flet/src/flet/controls/core/stack.py b/sdk/python/packages/flet/src/flet/controls/core/stack.py index 9842cff07..70023dd65 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/stack.py +++ b/sdk/python/packages/flet/src/flet/controls/core/stack.py @@ -57,3 +57,7 @@ class Stack(ConstrainedControl, AdaptiveControl): """ How to size the non-positioned [`controls`][flet.Stack.controls]. """ + + def init(self): + super().init() + self._internals["host_positioned"] = True diff --git a/sdk/python/packages/flet/src/flet/controls/core/view.py b/sdk/python/packages/flet/src/flet/controls/core/view.py index 771703b8f..eb019156a 100644 --- a/sdk/python/packages/flet/src/flet/controls/core/view.py +++ b/sdk/python/packages/flet/src/flet/controls/core/view.py @@ -165,6 +165,10 @@ def confirm_pop(self, should_pop: bool) -> None: async def confirm_pop_async(self, should_pop: bool) -> None: await self._invoke_method_async("confirm_pop", {"should_pop": should_pop}) + def init(self): + super().init() + self._internals["host_expanded"] = True + # Magic methods def __contains__(self, item: Control) -> bool: return item in self.controls diff --git a/sdk/python/packages/flet/src/flet/controls/material/container.py b/sdk/python/packages/flet/src/flet/controls/material/container.py index 7be6d4a33..ba4414503 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/container.py +++ b/sdk/python/packages/flet/src/flet/controls/material/container.py @@ -352,3 +352,7 @@ def on_hover(e): ft.run(main) ``` """ + + def init(self): + super().init() + self._internals["skip_properties"] = ["width", "height"] diff --git a/sdk/python/packages/flet/src/flet/controls/material/datatable.py b/sdk/python/packages/flet/src/flet/controls/material/datatable.py index 20657d1c6..ee2455b34 100644 --- a/sdk/python/packages/flet/src/flet/controls/material/datatable.py +++ b/sdk/python/packages/flet/src/flet/controls/material/datatable.py @@ -75,12 +75,15 @@ class DataColumn(Control): If not set, the column will not be considered sortable. """ + def init(self): + super().init() + self._internals["skip_properties"] = ["tooltip"] + def before_update(self): super().before_update() assert isinstance(self.label, str) or ( isinstance(self.label, Control) and self.label.visible ), "label must a string or a visible control" - self._internals["skip_properties"] = ["tooltip"] @control("DataCell") diff --git a/sdk/python/packages/flet/src/flet/controls/object_patch.py b/sdk/python/packages/flet/src/flet/controls/object_patch.py index f78dfbe7f..cf8484aa5 100644 --- a/sdk/python/packages/flet/src/flet/controls/object_patch.py +++ b/sdk/python/packages/flet/src/flet/controls/object_patch.py @@ -651,7 +651,7 @@ def _compare_dataclasses(self, parent, path, src, dst, frozen): orig_frozen = getattr(dst, "_frozen", None) if orig_frozen is not None: del dst._frozen - dst.init() + dst.build() dst.before_update() if orig_frozen is not None: object.__setattr__(dst, "_frozen", orig_frozen) @@ -872,7 +872,7 @@ def control_setattr(obj, name, value): if self.control_cls and isinstance(item, self.control_cls): if not configure_setattr_only: - item.init() + item.build() item.before_update() object.__setattr__(item, "_initialized", True) yield item diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 8b05ee9aa..45675af21 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 ( @@ -17,8 +16,8 @@ from flet.auth.authorization import Authorization 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, @@ -45,7 +44,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(): @@ -71,14 +70,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") @@ -377,7 +368,7 @@ def __post_init__( ref, sess: "Session", ) -> None: - AdaptiveControl.__post_init__(self, ref) + PageView.__post_init__(self, ref) self._i = 1 self.__session = weakref.ref(sess) @@ -403,11 +394,13 @@ def get_control(self, id: int) -> Optional[BaseControl]: ```python import flet as ft + def main(page: ft.Page): x = ft.IconButton(ft.Icons.ADD) page.add(x) print(type(page.get_control(x.uid))) + ft.run(main) ``` """ @@ -455,7 +448,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( @@ -476,7 +469,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 72863fa55..700481017 100644 --- a/sdk/python/packages/flet/src/flet/controls/page_view.py +++ b/sdk/python/packages/flet/src/flet/controls/page_view.py @@ -11,7 +11,6 @@ from flet.controls.box import BoxDecoration from flet.controls.control import Control from flet.controls.control_event import ( - ControlEventHandler, Event, EventHandler, ) @@ -27,7 +26,6 @@ from flet.controls.material.navigation_bar import NavigationBar from flet.controls.material.navigation_drawer import NavigationDrawer from flet.controls.padding import Padding, PaddingValue -from flet.controls.scrollable_control import OnScrollEvent from flet.controls.theme import Theme from flet.controls.transform import OffsetValue from flet.controls.types import ( @@ -47,61 +45,161 @@ @dataclass class PageMediaData: + """ + Represents the environmental metrics of a page or window. + + This data is updated whenever the platform window or layout changes, + such as when rotating a device, resizing a browser window, or adjusting + system UI elements like the keyboard or safe areas. + """ + padding: Padding + """ + The space surrounding the entire display, accounting for system UI + like notches and status bars. + """ + view_padding: Padding + """ + Similar to `padding`, but includes padding that is always reserved + (even when the system UI is hidden). + """ + view_insets: Padding + """ + Areas obscured by system UI overlays, such as the on-screen keyboard + or system gesture areas. + """ + + device_pixel_ratio: float + """ + The number of device pixels for each logical pixel. + """ @dataclass class PageResizeEvent(Event["PageView"]): + """ + Event fired when the size of the containing window or browser is changed. + + Typically used to adapt layout dynamically in response to resizes, + such as switching between compact and expanded views in a responsive design. + """ + width: float + """ + The new width of the page in logical pixels. + """ + height: float + """ + The new height of the page in logical pixels. + """ @control("PageView", isolated=True, kw_only=True) class PageView(AdaptiveControl): """ - TBD + A visual container representing a top-level view in a Flet application. + + `PageView` serves as the base class for [Page][flet.Page] and + [MultiView][flet.MultiView], and provides a unified surface for rendering + application content, app bars, + navigation elements, dialogs, overlays, and more. It manages one + or more [View][flet.View] instances and exposes high-level layout, + scrolling, and theming properties. + + Unlike lower-level layout controls (e.g., [Column][flet.Column], + [Container][flet.Container]), [PageView][flet.PageView] represents + an entire logical view or screen of the app. It provides direct access + to view-level controls such as [AppBar][flet.AppBar], + [NavigationBar][flet.NavigationBar], + [FloatingActionButton][flet.FloatingActionButton], + and supports system-level events like window resizing and media changes. + + This class is not intended to be used directly in most apps; instead, + use [Page][flet.Page] or [MultiView][flet.MultiView], which extend this base + functionality. """ views: list[View] = field(default_factory=lambda: [View()]) - _overlay: "Overlay" = field(default_factory=lambda: Overlay()) - _dialogs: "Dialogs" = field(default_factory=lambda: Dialogs()) + """ + A list of views managed by the page. + + Each [View][flet.View] represents a distinct navigation state or screen + in the application. + + The first view in the list is considered the active one by default. + """ theme_mode: Optional[ThemeMode] = ThemeMode.SYSTEM """ The page's theme mode. - - Defaults to `ThemeMode.SYSTEM`. """ + theme: Optional[Theme] = None """ Customizes the theme of the application when in light theme mode. Currently, a theme can only be automatically generated from a "seed" color. For example, to generate light theme from a green color. """ + dark_theme: Optional[Theme] = None """ Customizes the theme of the application when in dark theme mode. """ locale_configuration: Optional[LocaleConfiguration] = None + """ + Configures supported locales and the current locale. + """ show_semantics_debugger: Optional[bool] = None """ - `True` turns on an overlay that shows the accessibility information reported by the - framework. + Whether to turn on an overlay that shows the accessibility information + reported by the framework. """ width: Optional[Number] = None + """ + Page width in logical pixels. + + Note: + - This property is read-only. + - To get or set the full window height including window chrome (e.g., + title bar and borders) when running a Flet app on desktop, + use the [`width`][flet.Window.width] property of + [`Page.window`][flet.Page.window] instead. + """ height: Optional[Number] = None + """ + Page height in logical pixels. + + Note: + - This property is read-only. + - To get or set the full window height including window chrome (e.g., + title bar and borders) when running a Flet app on desktop, + use the [`height`][flet.Window.height] property of + [`Page.window`][flet.Page.window] instead. + """ title: Optional[str] = None + """ + Page or window title. + """ - media: Optional[PageMediaData] = None - - scroll_event_interval: Optional[Number] = None + media: PageMediaData = field( + default_factory=lambda: PageMediaData( + padding=Padding.zero(), + view_padding=Padding.zero(), + view_insets=Padding.zero(), + device_pixel_ratio=0, + ) + ) + """ + Represents the environmental metrics of a page or window. + """ on_resize: Optional[EventHandler["PageResizeEvent"]] = None """ @@ -115,18 +213,15 @@ def page_resize(e): page.on_resize = page_resize ``` """ - on_media_change: Optional[ControlEventHandler["PageView"]] = None - """ - Called when `page.media` has changed. - - Event type: [`PageMediaData`][flet.PageMediaData] - """ - on_scroll: Optional[EventHandler["OnScrollEvent"]] = None + on_media_change: Optional[EventHandler[PageMediaData]] = None """ - Called when page's scroll position is changed by a user. + Called when `media` has changed. """ + _overlay: "Overlay" = field(default_factory=lambda: Overlay()) + _dialogs: "Dialogs" = field(default_factory=lambda: Dialogs()) + def __default_view(self) -> View: assert len(self.views) > 0, "views list is empty." return self.views[0] @@ -278,6 +373,13 @@ def controls(self, value: list[BaseControl]): # appbar @property def appbar(self) -> Union[AppBar, CupertinoAppBar, None]: + """ + Gets or sets the top application bar ([AppBar][flet.AppBar] or + [CupertinoAppBar][flet.CupertinoAppBar]) for the view. + + The app bar typically displays the page title and optional actions + such as navigation icons, menus, or other interactive elements. + """ return self.__default_view().appbar @appbar.setter @@ -435,6 +537,10 @@ def __contains__(self, item: Control) -> bool: class Overlay(BaseControl): controls: list[BaseControl] = field(default_factory=list) + def init(self): + super().init() + self._internals["host_positioned"] = True + @control("Dialogs") class Dialogs(BaseControl): diff --git a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py index 888bb5f4f..a6ea46bf9 100644 --- a/sdk/python/packages/flet/src/flet/controls/scrollable_control.py +++ b/sdk/python/packages/flet/src/flet/controls/scrollable_control.py @@ -52,15 +52,18 @@ class ScrollableControl(Control): Defaults to `ScrollMode.None`. """ + auto_scroll: bool = False """ `True` if scrollbar should automatically move its position to the end when children updated. Must be `False` for `scroll_to()` method to work. """ + scroll_interval: Number = 10 """ Throttling in milliseconds for `on_scroll` event. """ + on_scroll: Optional[EventHandler[OnScrollEvent]] = None """ Called when scroll position is changed by a user. @@ -108,6 +111,7 @@ def scroll_to( ```python import flet as ft + def main(page: ft.Page): cl = ft.Column( spacing=10, @@ -126,6 +130,7 @@ def scroll_to_key(e): ft.ElevatedButton("Scroll to key '20'", on_click=scroll_to_key), ) + ft.run(main) ``` @@ -185,6 +190,7 @@ async def scroll_to_async( ```python import flet as ft + def main(page: ft.Page): cl = ft.Column( spacing=10, @@ -203,6 +209,7 @@ def scroll_to_key(e): ft.ElevatedButton("Scroll to key '20'", on_click=scroll_to_key), ) + ft.run(main) ``` diff --git a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py index 2213c2ac1..4073ef5d9 100644 --- a/sdk/python/packages/flet/src/flet/controls/services/file_picker.py +++ b/sdk/python/packages/flet/src/flet/controls/services/file_picker.py @@ -5,20 +5,22 @@ from flet.controls.base_control import control from flet.controls.control_event import Event, EventHandler +from flet.controls.exceptions import FletUnsupportedPlatformException from flet.controls.services.service import Service __all__ = [ "FilePicker", - "FilePickerUploadEvent", + "FilePickerFile", "FilePickerFileType", + "FilePickerUploadEvent", "FilePickerUploadFile", - "FilePickerFile", ] class FilePickerFileType(Enum): """ - Defines the file types that can be selected using the [`FilePicker`][flet.FilePicker]. + Defines the file types that can be selected using the + [`FilePicker`][flet.FilePicker]. """ ANY = "any" @@ -28,7 +30,8 @@ class FilePickerFileType(Enum): MEDIA = "media" """ - A combination of [`VIDEO`][flet.FilePickerFileType.VIDEO] and [`IMAGE`][flet.FilePickerFileType.IMAGE]. + A combination of [`VIDEO`][flet.FilePickerFileType.VIDEO] and + [`IMAGE`][flet.FilePickerFileType.IMAGE]. """ IMAGE = "image" @@ -130,12 +133,14 @@ async def upload_async(self, files: list[FilePickerUploadFile]): """ Uploads selected files to specified upload URLs. - Before calling this method, [`pick_files_async()`][flet.FilePicker.pick_files_async] + Before calling this method, + [`pick_files_async()`][flet.FilePicker.pick_files_async] must be called, so that the internal file picker selection is not empty. Args: files: A list of [`FilePickerUploadFile`][flet.FilePickerUploadFile], where - each item specifies which file to upload, and where (with PUT or POST). + each item specifies which file to upload, and where + (with PUT or POST). """ await self._invoke_method_async( "upload", @@ -146,12 +151,14 @@ def upload(self, files: list[FilePickerUploadFile]): """ Uploads selected files to specified upload URLs. - Before calling this method, [`pick_files_async()`][flet.FilePicker.pick_files_async] + Before calling this method, + [`pick_files_async()`][flet.FilePicker.pick_files_async] must be called, so that the internal file picker selection is not empty. Args: files: A list of [`FilePickerUploadFile`][flet.FilePickerUploadFile], where - each item specifies which file to upload, and where (with PUT or POST). + each item specifies which file to upload, and where + (with PUT or POST). """ asyncio.create_task(self.upload_async(files)) @@ -166,7 +173,15 @@ async def get_directory_path_async( Args: dialog_title: The title of the dialog window. Defaults to [`FilePicker. initial_directory: The initial directory where the dialog should open. + + Raises: + NotImplementedError: if called in web app. """ + if self.page.web: + raise FletUnsupportedPlatformException( + "get_directory_path is not supported on web" + ) + return await self._invoke_method_async( "get_directory_path", { @@ -194,48 +209,29 @@ async def save_file_async( file_name: The default file name. initial_directory: The initial directory where the dialog should open. file_type: The file types allowed to be selected. - src_bytes: TBA + src_bytes: The contents of a file. Must be provided in web, + iOS or Android modes. allowed_extensions: The allowed file extensions. Has effect only if - `file_type` is [`FilePickerFileType.CUSTOM`][flet.FilePickerFileType.CUSTOM]. + `file_type` is + [`FilePickerFileType.CUSTOM`][flet.FilePickerFileType.CUSTOM]. Note: - - This method only opens a dialog for the user to select a location and file name, - and returns the chosen path. The file itself is not created or saved. - - This method is only available on desktop platforms (Linux, macOS & Windows). - - Info: Saving a file on web - To save a file from the web, you actually don't need to use a `FilePicker`. + - On desktop this method only opens a dialog for the user to select + a location and file name, and returns the chosen path. The file + itself is not created or saved. - You can instead provide an API endpoint `/download/:filename` that returns the - file content, and then use - [`page.launch_url`][flet.Page.launch_url] to open the url, which - will trigger the browser's save file dialog. - - Take [FastAPI](https://flet.dev/docs/publish/web/dynamic-website#advanced-fastapi-scenarios) - as an example, you can use the following code to implement the endpoint: - - ```python - from fastapi import FastAPI, Response - from fastapi.responses import FileResponse - - app = flet_fastapi.app(main) - - @app.get("/download/{filename}") - def download(filename: str): - path = prepare_file(filename) - return FileResponse(path) - ``` - - and then use `page.launch_url("/download/myfile.txt")` to open the url, for - instance, when a button is clicked. + Raises: + ValueError: if `src_bytes` is not provided in web, iOS or Android modes. + ValueError: if `file_name` is not provided in web mode. + """ - ```python - ft.ElevatedButton( - "Download myfile", - on_click=lambda _: page.launch_url("/download/myfile.txt"), + if (self.page.web or self.page.platform.is_mobile()) and not src_bytes: + raise ValueError( + '"src_bytes" is required when saving a file on Web, Android and iOS.' ) - ``` - """ + if self.page.web and not file_name: + raise ValueError('"file_name" is required when saving a file on Web.') + return await self._invoke_method_async( "save_file", { @@ -266,7 +262,8 @@ async def pick_files_async( file_type: The file types allowed to be selected. allow_multiple: Allow the selection of multiple files at once. allowed_extensions: The allowed file extensions. Has effect only if - `file_type` is [`FilePickerFileType.CUSTOM`][flet.FilePickerFileType.CUSTOM]. + `file_type` is + [`FilePickerFileType.CUSTOM`][flet.FilePickerFileType.CUSTOM]. """ files = await self._invoke_method_async( "pick_files", diff --git a/sdk/python/packages/flet/src/flet/messaging/session.py b/sdk/python/packages/flet/src/flet/messaging/session.py index c0505bd9e..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, get_event_field_type +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 = 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/src/flet/testing/flet_test_app.py b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py index e36f276b1..e962bd46a 100644 --- a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py +++ b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py @@ -2,6 +2,7 @@ import logging import os import platform +import tempfile from io import BytesIO from pathlib import Path from typing import Any, Optional @@ -16,8 +17,8 @@ from flet.utils.network import get_free_tcp_port from flet.utils.platform_utils import get_bool_env_var -pixel_ratio = float(os.getenv("FLET_TEST_SCREENSHOTS_PIXEL_RATIO", "2.0")) -similarity_threshold = float(os.getenv("FLET_TEST_SIMILARITY_THRESHOLD", "99.0")) +DEFAULT_SCREENSHOTS_PIXEL_RATIO = "2.0" +DEFAULT_SIMILARITY_THRESHOLD = "99.0" class FletTestApp: @@ -25,6 +26,7 @@ def __init__( self, flutter_app_dir: os.PathLike, flet_app_main: Any = None, + assets_dir: Optional[os.PathLike] = None, test_path: Optional[str] = None, tcp_port: Optional[int] = None, ): @@ -32,9 +34,18 @@ def __init__( Flet app test controller is a bridge that connects together a Flet app in Python and a running integration test in Flutter. """ + self.pixel_ratio = float( + os.getenv( + "FLET_TEST_SCREENSHOTS_PIXEL_RATIO", DEFAULT_SCREENSHOTS_PIXEL_RATIO + ) + ) + self.similarity_threshold = float( + os.getenv("FLET_TEST_SIMILARITY_THRESHOLD", DEFAULT_SIMILARITY_THRESHOLD) + ) self.__test_path = test_path self.__flet_app_main = flet_app_main self.__flutter_app_dir = flutter_app_dir + self.__assets_dir = assets_dir or "assets" self.__tcp_port = tcp_port self.__flutter_process: Optional[asyncio.subprocess.Process] = None self.__page = None @@ -79,7 +90,16 @@ async def main(page: ft.Page): if not self.__tcp_port: self.__tcp_port = get_free_tcp_port() - asyncio.create_task(ft.run_async(main, port=self.__tcp_port, view=None)) + use_http = get_bool_env_var("FLET_TEST_USE_HTTP") + + if use_http: + os.environ["FLET_FORCE_WEB_SERVER"] = "true" + + asyncio.create_task( + ft.run_async( + main, port=self.__tcp_port, assets_dir=str(self.__assets_dir), view=None + ) + ) print("Started Flet app") stdout = asyncio.subprocess.DEVNULL @@ -101,13 +121,22 @@ async def main(page: ft.Page): self.test_device = os.getenv("FLET_TEST_DEVICE", self.test_platform) tcp_addr = "10.0.2.2" if self.test_platform == "android" else "127.0.0.1" + protocol = "http" if use_http else "tcp" if self.test_device: flutter_args += ["-d", self.test_device] - app_url = f"tcp://{tcp_addr}:{self.__tcp_port}" + app_url = f"{protocol}://{tcp_addr}:{self.__tcp_port}" flutter_args += [f"--dart-define=FLET_TEST_APP_URL={app_url}"] + if not use_http: + temp_path = Path(tempfile.gettempdir()) / "flet_app_pid.txt" + flutter_args += [f"--dart-define=FLET_TEST_PID_FILE_PATH={temp_path}"] + if self.__assets_dir: + flutter_args += [ + f"--dart-define=FLET_TEST_ASSETS_DIR={self.__assets_dir}" + ] + self.__flutter_process = await asyncio.create_subprocess_exec( *flutter_args, cwd=str(self.__flutter_app_dir), @@ -147,7 +176,13 @@ async def teardown(self): print("Force killing Flutter test process...") self.__flutter_process.kill() - async def assert_control_screenshot(self, name: str, control: Control): + async def assert_control_screenshot( + self, + name: str, + control: Control, + pump_times: int = 0, + pump_duration: Optional[ft.DurationValue] = None, + ): """ Adds control to a clean page, takes a screenshot and compares it with a golden copy or takes golden screenshot if `FLET_TEST_GOLDEN=1` @@ -159,14 +194,17 @@ async def assert_control_screenshot(self, name: str, control: Control): """ # clean page self.page.clean() + await self.tester.pump_and_settle() # add control and take screenshot screenshot = ft.Screenshot(control) self.page.add(screenshot) await self.tester.pump_and_settle() + for _ in range(0, pump_times): + await self.tester.pump(duration=pump_duration) self.assert_screenshot( name, - await screenshot.capture_async(pixel_ratio=pixel_ratio), + await screenshot.capture_async(pixel_ratio=self.pixel_ratio), ) def assert_screenshot(self, name: str, screenshot: bytes): @@ -205,14 +243,14 @@ def assert_screenshot(self, name: str, screenshot: bytes): img = self._load_image_from_bytes(screenshot) similarity = self._compare_images_rgb(golden_img, img) print(f"Similarity for {name}: {similarity}%") - if similarity <= similarity_threshold: + if similarity <= self.similarity_threshold: actual_image_path = ( golden_image_path.parent / f"{golden_image_path.parent.stem}_{golden_image_path.stem}_actual.png" ) with open(actual_image_path, "bw") as f: f.write(screenshot) - assert similarity > similarity_threshold, ( + assert similarity > self.similarity_threshold, ( f"{name} screenshots are not identical" ) diff --git a/sdk/python/packages/flet/src/flet/testing/tester.py b/sdk/python/packages/flet/src/flet/testing/tester.py index 8cfb19fac..654aa9568 100644 --- a/sdk/python/packages/flet/src/flet/testing/tester.py +++ b/sdk/python/packages/flet/src/flet/testing/tester.py @@ -1,7 +1,7 @@ from typing import Optional from flet.controls.base_control import control -from flet.controls.duration import Duration +from flet.controls.duration import DurationValue from flet.controls.keys import KeyValue from flet.controls.services.service import Service from flet.controls.types import IconValue @@ -16,7 +16,7 @@ class Tester(Service): Class that programmatically interacts with page controls and the test environment. """ - async def pump(self, duration: Optional[Duration] = None): + async def pump(self, duration: Optional[DurationValue] = None): """ Triggers a frame after duration amount of time. @@ -25,7 +25,7 @@ async def pump(self, duration: Optional[Duration] = None): """ return await self._invoke_method_async("pump", {"duration": duration}) - async def pump_and_settle(self): + async def pump_and_settle(self, duration: Optional[DurationValue] = None): """ Repeatedly calls pump until there are no longer any frames scheduled. This will call `pump` at least once, even if no frames are scheduled when @@ -33,8 +33,13 @@ async def pump_and_settle(self): themselves schedule a frame. This essentially waits for all animations to have completed. + + Args: + duration: A duration after which to trigger a frame. """ - return await self._invoke_method_async("pump_and_settle") + return await self._invoke_method_async( + "pump_and_settle", {"duration": duration} + ) async def find_by_text(self, text: str) -> Finder: """ diff --git a/sdk/python/packages/flet/tests/common.py b/sdk/python/packages/flet/tests/common.py index b8f90ef0c..98f6a14d9 100644 --- a/sdk/python/packages/flet/tests/common.py +++ b/sdk/python/packages/flet/tests/common.py @@ -35,15 +35,10 @@ class LineChart(ft.ConstrainedControl): ) ) interactive: bool = True - _skip_inherited_notifier: Optional[bool] = None - - def __post_init__(self, ref: Optional[ft.Ref[Any]]): - super().__post_init__(ref) - self._internals["skip_properties"] = ["tooltip"] def init(self): super().init() - self._skip_inherited_notifier = True + self._internals["skip_properties"] = ["tooltip"] def b_pack(data): 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, }, ) diff --git a/sdk/python/packages/flet/tests/test_object_diff_frozen.py b/sdk/python/packages/flet/tests/test_object_diff_frozen.py index 73ae92769..44273e0b9 100644 --- a/sdk/python/packages/flet/tests/test_object_diff_frozen.py +++ b/sdk/python/packages/flet/tests/test_object_diff_frozen.py @@ -1,11 +1,10 @@ from dataclasses import dataclass from typing import Optional +import flet as ft import pytest from flet.controls.base_control import BaseControl, control -import flet as ft - from .common import ( LineChart, LineChartData, @@ -333,7 +332,6 @@ def test_lists_with_key_diff(): "path": ["data_series", 0, "points", 2], "value_type": LineChartDataPoint, }, - {"op": "replace", "path": ["_skip_inherited_notifier"], "value": True}, ], ) assert patch[2]["value"].x == 3 @@ -395,7 +393,6 @@ def test_lists_with_no_key_diff(): "path": ["data_series", 0, "points", 2, "y"], "value": 5, }, - {"op": "replace", "path": ["_skip_inherited_notifier"], "value": True}, ], ) @@ -410,7 +407,6 @@ def test_simple_lists_diff_1(): [ {"op": "remove", "path": ["data_series", 0, "points", 0], "value": 1}, {"op": "add", "path": ["data_series", 0, "points", 2], "value": 4}, - {"op": "replace", "path": ["_skip_inherited_notifier"], "value": True}, ], ) @@ -424,7 +420,6 @@ def test_simple_lists_diff_2(): patch, [ {"op": "remove", "path": ["data_series", 0, "points", 1], "value": 2}, - {"op": "replace", "path": ["_skip_inherited_notifier"], "value": True}, ], ) @@ -447,7 +442,6 @@ def test_similar_lists_diff(): "path": ["data_series", 0, "points", 1, "scale"], "value": 2, }, - {"op": "replace", "path": ["_skip_inherited_notifier"], "value": True}, ], ) diff --git a/sdk/python/packages/flet/tests/test_object_diff_in_place.py b/sdk/python/packages/flet/tests/test_object_diff_in_place.py index 3317db3b8..f1d9ca388 100644 --- a/sdk/python/packages/flet/tests/test_object_diff_in_place.py +++ b/sdk/python/packages/flet/tests/test_object_diff_in_place.py @@ -40,6 +40,10 @@ class SuperElevatedButton(ElevatedButton): def init(self): print("SuperElevatedButton.init()") + assert not self.page + + def build(self): + print("SuperElevatedButton.build()") assert self.page