flet-charts 0.85.0.dev3__tar.gz → 0.85.0.dev4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {flet_charts-0.85.0.dev3/src/flet_charts.egg-info → flet_charts-0.85.0.dev4}/PKG-INFO +2 -2
  2. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/pyproject.toml +2 -2
  3. flet_charts-0.85.0.dev4/src/flet_charts/matplotlib_backends/backend_flet_agg.py +20 -0
  4. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/matplotlib_chart.py +3 -65
  5. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4/src/flet_charts.egg-info}/PKG-INFO +2 -2
  6. flet_charts-0.85.0.dev4/src/flet_charts.egg-info/requires.txt +1 -0
  7. flet_charts-0.85.0.dev4/src/flutter/flet_charts/lib/src/matplotlib_chart_canvas.dart +457 -0
  8. flet_charts-0.85.0.dev3/src/flet_charts/matplotlib_backends/backend_flet_agg.py +0 -54
  9. flet_charts-0.85.0.dev3/src/flet_charts.egg-info/requires.txt +0 -1
  10. flet_charts-0.85.0.dev3/src/flutter/flet_charts/lib/src/matplotlib_chart_canvas.dart +0 -276
  11. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/LICENSE +0 -0
  12. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/README.md +0 -0
  13. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/setup.cfg +0 -0
  14. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/__init__.py +0 -0
  15. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/bar_chart.py +0 -0
  16. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/bar_chart_group.py +0 -0
  17. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/bar_chart_rod.py +0 -0
  18. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/bar_chart_rod_stack_item.py +0 -0
  19. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/candlestick_chart.py +0 -0
  20. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/candlestick_chart_spot.py +0 -0
  21. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/chart_axis.py +0 -0
  22. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/line_chart.py +0 -0
  23. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/line_chart_data.py +0 -0
  24. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/line_chart_data_point.py +0 -0
  25. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/matplotlib_chart_canvas.py +0 -0
  26. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/matplotlib_chart_with_toolbar.py +0 -0
  27. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/pie_chart.py +0 -0
  28. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/pie_chart_section.py +0 -0
  29. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/plotly_chart.py +0 -0
  30. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/radar_chart.py +0 -0
  31. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/radar_data_set.py +0 -0
  32. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/scatter_chart.py +0 -0
  33. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/scatter_chart_spot.py +0 -0
  34. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts/types.py +0 -0
  35. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts.egg-info/SOURCES.txt +0 -0
  36. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts.egg-info/dependency_links.txt +0 -0
  37. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flet_charts.egg-info/top_level.txt +0 -0
  38. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/flet_charts.dart +0 -0
  39. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/bar_chart.dart +0 -0
  40. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/candlestick_chart.dart +0 -0
  41. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/extension.dart +0 -0
  42. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/line_chart.dart +0 -0
  43. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/pie_chart.dart +0 -0
  44. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/radar_chart.dart +0 -0
  45. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/scatter_chart.dart +0 -0
  46. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/bar_chart.dart +0 -0
  47. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/candlestick_chart.dart +0 -0
  48. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/charts.dart +0 -0
  49. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/line_chart.dart +0 -0
  50. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/pie_chart.dart +0 -0
  51. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/radar_chart.dart +0 -0
  52. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/lib/src/utils/scatter_chart.dart +0 -0
  53. {flet_charts-0.85.0.dev3 → flet_charts-0.85.0.dev4}/src/flutter/flet_charts/pubspec.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-charts
3
- Version: 0.85.0.dev3
3
+ Version: 0.85.0.dev4
4
4
  Summary: Interactive chart controls for Flet apps.
5
5
  Author-email: Flet contributors <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/flet-dev/flet/issues
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: flet==0.85.0.dev3
14
+ Requires-Dist: flet==0.85.0.dev4
15
15
  Dynamic: license-file
16
16
 
17
17
  # flet-charts
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "flet-charts"
3
- version = "0.85.0.dev3"
3
+ version = "0.85.0.dev4"
4
4
  description = "Interactive chart controls for Flet apps."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Flet contributors", email = "hello@flet.dev" }]
7
7
  license = "Apache-2.0"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
- "flet==0.85.0.dev3",
10
+ "flet==0.85.0.dev4",
11
11
  ]
12
12
 
13
13
  [project.urls]
@@ -0,0 +1,20 @@
1
+ from matplotlib import _api
2
+ from matplotlib.backends import backend_webagg_core
3
+
4
+
5
+ class FigureCanvasFletAgg(backend_webagg_core.FigureCanvasWebAggCore):
6
+ """Canvas implementation used to render Matplotlib figures in Flet."""
7
+
8
+ manager_class = _api.classproperty(lambda cls: FigureManagerFletAgg)
9
+ supports_blit = False
10
+
11
+
12
+ class FigureManagerFletAgg(backend_webagg_core.FigureManagerWebAgg):
13
+ """Figure manager binding Matplotlib WebAgg tooling to Flet transport."""
14
+
15
+ _toolbar2_class = backend_webagg_core.NavigationToolbar2WebAgg
16
+
17
+
18
+ FigureCanvas = FigureCanvasFletAgg
19
+ FigureManager = FigureManagerFletAgg
20
+ interactive = True
@@ -1,7 +1,5 @@
1
1
  import asyncio
2
2
  import logging
3
- import sys
4
- import threading
5
3
  from dataclasses import dataclass, field
6
4
  from io import BytesIO
7
5
  from typing import Any, Optional
@@ -12,11 +10,6 @@ from flet_charts.matplotlib_chart_canvas import (
12
10
  MatplotlibChartCanvasResizeEvent,
13
11
  )
14
12
 
15
- # Pyodide / WASM has no real threads — `asyncio.to_thread` runs synchronously
16
- # on the same thread there, providing no benefit. Fall back to a same-loop
17
- # render path on those platforms.
18
- _HAS_THREADS = sys.platform != "emscripten"
19
-
20
13
  _MATPLOTLIB_IMPORT_ERROR: Optional[ImportError] = None
21
14
 
22
15
  try:
@@ -169,12 +162,6 @@ class MatplotlibChart(ft.GestureDetector):
169
162
  self._width = 0
170
163
  self._height = 0
171
164
  self._waiting = False
172
- # Serializes worker-thread renders against main-thread matplotlib
173
- # operations like `figure.savefig()` (download). matplotlib's
174
- # print_figure temporarily nulls `canvas.manager` while saving, which
175
- # would crash an in-flight `canvas.draw()` running in our render
176
- # thread.
177
- self._mpl_lock = threading.Lock()
178
165
 
179
166
  def _on_key_down(self, e: ft.KeyboardEvent) -> None:
180
167
  """
@@ -429,8 +416,7 @@ class MatplotlibChart(ft.GestureDetector):
429
416
  """
430
417
  logger.debug(f"Download in format: {format}")
431
418
  buff = BytesIO()
432
- with self._mpl_lock:
433
- self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr)
419
+ self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr)
434
420
  return buff.getvalue()
435
421
 
436
422
  async def _receive_loop(self):
@@ -445,37 +431,14 @@ class MatplotlibChart(ft.GestureDetector):
445
431
  while True:
446
432
  is_binary, content = await self._receive_queue.get()
447
433
 
448
- # Coalesce stale items so interaction stays snappy:
449
- # - Drop a binary frame if a newer one is queued behind it.
450
- # - Drop a "draw" request if another is queued — the latest one
451
- # will trigger the render with the most up-to-date state.
452
- # Without this, every pointer event during pan/zoom triggers its
453
- # own render and the chart visibly "plays back" buffered motion
454
- # after the user releases the mouse.
455
- if is_binary:
456
- if any(it[0] for it in self._receive_queue._queue):
457
- continue
458
- elif (
459
- isinstance(content, dict)
460
- and content.get("type") == "draw"
461
- and any(
462
- not it[0]
463
- and isinstance(it[1], dict)
464
- and it[1].get("type") == "draw"
465
- for it in self._receive_queue._queue
466
- )
467
- ):
468
- continue
469
-
470
434
  if is_binary:
471
435
  assert isinstance(content, (bytes, bytearray))
472
436
  logger.debug(f"receive_binary({len(content)})")
473
- is_full = self.__image_mode == "full"
474
437
  # Hand the frame to the client widget — full PNG replaces the
475
438
  # backbuffer, diff PNG composites onto it. Awaiting naturally
476
439
  # rate-limits this loop to the client's processing speed and
477
440
  # yields the asyncio loop for incoming events.
478
- if is_full:
441
+ if self.__image_mode == "full":
479
442
  await self.mpl_canvas.apply_full(bytes(content))
480
443
  else:
481
444
  await self.mpl_canvas.apply_diff(bytes(content))
@@ -490,22 +453,7 @@ class MatplotlibChart(ft.GestureDetector):
490
453
  self.update()
491
454
  elif content["type"] == "draw" and not self._waiting:
492
455
  self._waiting = True
493
- if _HAS_THREADS:
494
- # Native runtime: render in a worker thread so the
495
- # asyncio loop stays free for input events. handle_draw
496
- # ends up in Agg/PIL C code that releases the GIL, so
497
- # threading is effective. _waiting + the queue-dedupe
498
- # above ensure only one render is ever in flight.
499
- # The lock prevents overlap with main-thread savefig.
500
- asyncio.create_task(asyncio.to_thread(self._draw_locked))
501
- else:
502
- # Pyodide / WASM: no real threads available. Render
503
- # synchronously on the loop. Yield first so any
504
- # backed-up pointer events can update matplotlib state
505
- # before the (blocking) render runs.
506
- for _ in range(10):
507
- await asyncio.sleep(0)
508
- self.send_message({"type": "draw"})
456
+ self.send_message({"type": "draw"})
509
457
  elif content["type"] == "rubberband":
510
458
  if (
511
459
  content["x0"] != -1
@@ -547,16 +495,6 @@ class MatplotlibChart(ft.GestureDetector):
547
495
  if manager is not None:
548
496
  manager.handle_json(message)
549
497
 
550
- def _draw_locked(self):
551
- """Worker-thread entry point for triggering a render.
552
-
553
- Holds `_mpl_lock` for the duration of the synchronous draw so it
554
- can't overlap with main-thread `figure.savefig()`, which temporarily
555
- nulls `canvas.manager` and would crash an in-flight render.
556
- """
557
- with self._mpl_lock:
558
- self.send_message({"type": "draw"})
559
-
560
498
  def send_json(self, content):
561
499
  """Sends a JSON message to the front end."""
562
500
  logger.debug(f"send_json: {content}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-charts
3
- Version: 0.85.0.dev3
3
+ Version: 0.85.0.dev4
4
4
  Summary: Interactive chart controls for Flet apps.
5
5
  Author-email: Flet contributors <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -11,7 +11,7 @@ Project-URL: Issues, https://github.com/flet-dev/flet/issues
11
11
  Requires-Python: >=3.10
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: flet==0.85.0.dev3
14
+ Requires-Dist: flet==0.85.0.dev4
15
15
  Dynamic: license-file
16
16
 
17
17
  # flet-charts
@@ -0,0 +1 @@
1
+ flet==0.85.0.dev4
@@ -0,0 +1,457 @@
1
+ import 'dart:async';
2
+ import 'dart:typed_data';
3
+ import 'dart:ui' as ui;
4
+
5
+ import 'package:flet/flet.dart';
6
+ import 'package:flutter/foundation.dart' show kIsWeb;
7
+ import 'package:flutter/material.dart';
8
+
9
+ /// Display widget for matplotlib WebAgg-style image streams.
10
+ ///
11
+ /// Two rendering strategies, picked at runtime by platform:
12
+ ///
13
+ /// - **GPU + flatten** (native): keeps an `_backdrop` plus a list of pending
14
+ /// diff `ui.Image`s, paints all of them per frame, and bakes them into a
15
+ /// fresh backdrop via `Picture.toImage` every [_GpuMatplotlibChartCanvasState._flattenInterval]
16
+ /// diffs. Fast (no GPU↔CPU readback) and memory-stable on native runtimes
17
+ /// where Dart GC is aggressive enough to reclaim layer-held SkImage refs.
18
+ ///
19
+ /// - **CPU composite** (web): decodes each PNG to RGBA bytes, composites
20
+ /// onto a single backbuffer in Dart, and uploads ONE fresh `ui.Image` per
21
+ /// frame. Slower per frame, but holds at most one `ui.Image` at a time so
22
+ /// layer-ref accumulation stays bounded under Flutter web (CanvasKit/WASM)
23
+ /// where Dart GC doesn't promptly reclaim native SkImage refs.
24
+ class MatplotlibChartCanvasControl extends StatefulWidget {
25
+ final Control control;
26
+
27
+ MatplotlibChartCanvasControl({Key? key, required this.control})
28
+ : super(key: key ?? ValueKey("control_${control.id}"));
29
+
30
+ @override
31
+ // ignore: no_logic_in_create_state
32
+ State<MatplotlibChartCanvasControl> createState() => kIsWeb
33
+ ? _CpuMatplotlibChartCanvasState()
34
+ : _GpuMatplotlibChartCanvasState();
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Shared helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ Uint8List _extractBytes(dynamic args) {
42
+ final v = args is Map ? args["bytes"] : args;
43
+ if (v is Uint8List) return v;
44
+ if (v is ByteData) {
45
+ return v.buffer.asUint8List(v.offsetInBytes, v.lengthInBytes);
46
+ }
47
+ if (v is List<int>) return Uint8List.fromList(v);
48
+ if (v is List && v.every((e) => e is int)) {
49
+ return Uint8List.fromList(v.cast<int>());
50
+ }
51
+ throw ArgumentError("Expected bytes for image data, got ${v.runtimeType}");
52
+ }
53
+
54
+ abstract class _MatplotlibChartCanvasStateBase
55
+ extends State<MatplotlibChartCanvasControl> {
56
+ // Serialize concurrent apply_full / apply_diff calls so backdrop mutations
57
+ // happen in arrival order.
58
+ Future<void>? _applyChain;
59
+
60
+ Size _lastSize = Size.zero;
61
+ int _lastResize = DateTime.now().millisecondsSinceEpoch;
62
+
63
+ @override
64
+ void initState() {
65
+ super.initState();
66
+ widget.control.addInvokeMethodListener(_invokeMethod);
67
+ }
68
+
69
+ @override
70
+ void dispose() {
71
+ widget.control.removeInvokeMethodListener(_invokeMethod);
72
+ disposeResources();
73
+ super.dispose();
74
+ }
75
+
76
+ /// Subclass hook — release any held `ui.Image`s / backbuffers.
77
+ void disposeResources();
78
+
79
+ Future<void> applyFull(Uint8List bytes);
80
+ Future<void> applyDiff(Uint8List bytes);
81
+ Future<void> clearAll();
82
+ CustomPainter buildPainter();
83
+
84
+ Future<dynamic> _invokeMethod(String name, dynamic args) async {
85
+ switch (name) {
86
+ case "apply_full":
87
+ await _enqueue(() => applyFull(_extractBytes(args)));
88
+ return;
89
+ case "apply_diff":
90
+ await _enqueue(() => applyDiff(_extractBytes(args)));
91
+ return;
92
+ case "clear":
93
+ await _enqueue(clearAll);
94
+ return;
95
+ default:
96
+ throw Exception("Unknown MatplotlibChartCanvas method: $name");
97
+ }
98
+ }
99
+
100
+ Future<void> _enqueue(Future<void> Function() task) {
101
+ final prev = _applyChain ?? Future.value();
102
+ final next = prev.then((_) => task());
103
+ _applyChain = next.catchError((_) {});
104
+ return next;
105
+ }
106
+
107
+ void _maybeReportResize(Size size) {
108
+ final resizeInterval = widget.control.getInt("resize_interval", 10)!;
109
+ final now = DateTime.now().millisecondsSinceEpoch;
110
+ if ((now - _lastResize > resizeInterval && _lastSize != size) ||
111
+ _lastSize.isEmpty) {
112
+ _lastSize = size;
113
+ _lastResize = now;
114
+ widget.control
115
+ .triggerEvent("resize", {"w": size.width, "h": size.height});
116
+ }
117
+ }
118
+
119
+ @override
120
+ Widget build(BuildContext context) {
121
+ return LayoutBuilder(
122
+ builder: (context, constraints) {
123
+ WidgetsBinding.instance.addPostFrameCallback((_) {
124
+ if (!mounted) return;
125
+ _maybeReportResize(constraints.biggest);
126
+ });
127
+ return CustomPaint(
128
+ size: constraints.biggest,
129
+ painter: buildPainter(),
130
+ );
131
+ },
132
+ );
133
+ }
134
+ }
135
+
136
+ /// Decodes PNG bytes to a [ui.Image], staying GPU-resident.
137
+ Future<ui.Image?> _decodeImage(Uint8List bytes) async {
138
+ if (bytes.isEmpty) {
139
+ debugPrint("MatplotlibChartCanvas: skipping empty image bytes");
140
+ return null;
141
+ }
142
+ // Defensive copy; Safari's WASM runtime can free underlying buffers across
143
+ // async boundaries and trigger "EncodingError: Loading error.".
144
+ final owned = Uint8List.fromList(bytes);
145
+ ui.Codec? codec;
146
+ try {
147
+ codec = await ui.instantiateImageCodec(owned, allowUpscaling: false);
148
+ final frame = await codec.getNextFrame();
149
+ return frame.image;
150
+ } catch (e) {
151
+ debugPrint(
152
+ "MatplotlibChartCanvas: decode failed (${owned.length} bytes): $e");
153
+ rethrow;
154
+ } finally {
155
+ codec?.dispose();
156
+ }
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // GPU + flatten strategy (native runtimes)
161
+ // ---------------------------------------------------------------------------
162
+
163
+ class _GpuMatplotlibChartCanvasState
164
+ extends _MatplotlibChartCanvasStateBase {
165
+ // Number of diffs to accumulate before flattening into a fresh backdrop.
166
+ // Larger = fewer Picture.toImage calls; smaller = lower transient memory.
167
+ static const int _flattenInterval = 10;
168
+
169
+ ui.Image? _backdrop;
170
+ final List<ui.Image> _diffs = [];
171
+
172
+ @override
173
+ void disposeResources() {
174
+ _backdrop?.dispose();
175
+ _backdrop = null;
176
+ for (final img in _diffs) {
177
+ img.dispose();
178
+ }
179
+ _diffs.clear();
180
+ }
181
+
182
+ @override
183
+ Future<void> applyFull(Uint8List bytes) async {
184
+ final image = await _decodeImage(bytes);
185
+ if (image == null) return;
186
+ _replaceBackdrop(image);
187
+ _disposeDiffs();
188
+ if (mounted) setState(() {});
189
+ }
190
+
191
+ @override
192
+ Future<void> applyDiff(Uint8List bytes) async {
193
+ if (_backdrop == null) {
194
+ // No baseline yet — first frame must be a full.
195
+ await applyFull(bytes);
196
+ return;
197
+ }
198
+ final image = await _decodeImage(bytes);
199
+ if (image == null) return;
200
+ _diffs.add(image);
201
+ if (_diffs.length >= _flattenInterval) {
202
+ await _flatten();
203
+ }
204
+ if (mounted) setState(() {});
205
+ }
206
+
207
+ @override
208
+ Future<void> clearAll() async {
209
+ _replaceBackdrop(null);
210
+ _disposeDiffs();
211
+ if (mounted) setState(() {});
212
+ }
213
+
214
+ /// Bakes [_backdrop] + pending [_diffs] into a single new backdrop via
215
+ /// `Picture.toImage`, replaces [_backdrop], and drops the diffs.
216
+ Future<void> _flatten() async {
217
+ final backdrop = _backdrop;
218
+ if (backdrop == null || _diffs.isEmpty) return;
219
+
220
+ final w = backdrop.width;
221
+ final h = backdrop.height;
222
+
223
+ final recorder = ui.PictureRecorder();
224
+ final canvas = Canvas(recorder);
225
+ final paint = Paint();
226
+ canvas.drawImage(backdrop, Offset.zero, paint);
227
+ for (final diff in _diffs) {
228
+ canvas.drawImage(diff, Offset.zero, paint);
229
+ }
230
+
231
+ final picture = recorder.endRecording();
232
+ final ui.Image newBackdrop;
233
+ try {
234
+ newBackdrop = await picture.toImage(w, h);
235
+ } finally {
236
+ picture.dispose();
237
+ }
238
+
239
+ _replaceBackdrop(newBackdrop);
240
+ _disposeDiffs();
241
+ }
242
+
243
+ void _replaceBackdrop(ui.Image? image) {
244
+ final old = _backdrop;
245
+ _backdrop = image;
246
+ if (old != null) {
247
+ WidgetsBinding.instance.addPostFrameCallback((_) {
248
+ old.dispose();
249
+ });
250
+ }
251
+ }
252
+
253
+ void _disposeDiffs() {
254
+ if (_diffs.isEmpty) return;
255
+ final old = List<ui.Image>.of(_diffs);
256
+ _diffs.clear();
257
+ WidgetsBinding.instance.addPostFrameCallback((_) {
258
+ for (final img in old) {
259
+ img.dispose();
260
+ }
261
+ });
262
+ }
263
+
264
+ @override
265
+ CustomPainter buildPainter() => _GpuMatplotlibImagePainter(
266
+ backdrop: _backdrop,
267
+ diffs: List<ui.Image>.unmodifiable(_diffs),
268
+ );
269
+ }
270
+
271
+ class _GpuMatplotlibImagePainter extends CustomPainter {
272
+ final ui.Image? backdrop;
273
+ final List<ui.Image> diffs;
274
+
275
+ const _GpuMatplotlibImagePainter({
276
+ required this.backdrop,
277
+ required this.diffs,
278
+ });
279
+
280
+ @override
281
+ void paint(Canvas canvas, Size size) {
282
+ final bg = backdrop;
283
+ if (bg == null) return;
284
+ final dst = Rect.fromLTWH(0, 0, size.width, size.height);
285
+ final paint = Paint();
286
+ final bgSrc =
287
+ Rect.fromLTWH(0, 0, bg.width.toDouble(), bg.height.toDouble());
288
+ canvas.drawImageRect(bg, bgSrc, dst, paint);
289
+ for (final diff in diffs) {
290
+ final src =
291
+ Rect.fromLTWH(0, 0, diff.width.toDouble(), diff.height.toDouble());
292
+ canvas.drawImageRect(diff, src, dst, paint);
293
+ }
294
+ }
295
+
296
+ @override
297
+ bool shouldRepaint(_GpuMatplotlibImagePainter old) {
298
+ return backdrop != old.backdrop || diffs.length != old.diffs.length;
299
+ }
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // CPU composite strategy (web — CanvasKit/WASM)
304
+ // ---------------------------------------------------------------------------
305
+
306
+ class _CpuMatplotlibChartCanvasState
307
+ extends _MatplotlibChartCanvasStateBase {
308
+ ui.Image? _displayImage;
309
+ Uint8List? _backbuffer;
310
+ int _bbWidth = 0;
311
+ int _bbHeight = 0;
312
+
313
+ @override
314
+ void disposeResources() {
315
+ _displayImage?.dispose();
316
+ _displayImage = null;
317
+ _backbuffer = null;
318
+ }
319
+
320
+ @override
321
+ Future<void> applyFull(Uint8List bytes) async {
322
+ final decoded = await _decodeRgba(bytes);
323
+ if (decoded == null) return;
324
+
325
+ _backbuffer = decoded.bytes;
326
+ _bbWidth = decoded.width;
327
+ _bbHeight = decoded.height;
328
+
329
+ final image = await _makeImage(decoded.bytes, decoded.width, decoded.height);
330
+ _swapDisplay(image);
331
+ }
332
+
333
+ @override
334
+ Future<void> applyDiff(Uint8List bytes) async {
335
+ if (_backbuffer == null) {
336
+ // No baseline yet — treat as full.
337
+ await applyFull(bytes);
338
+ return;
339
+ }
340
+
341
+ final decoded = await _decodeRgba(bytes);
342
+ if (decoded == null) return;
343
+
344
+ // Promote to a full replace if the frame size changed.
345
+ if (decoded.width != _bbWidth ||
346
+ decoded.height != _bbHeight ||
347
+ decoded.bytes.length != _backbuffer!.length) {
348
+ _backbuffer = decoded.bytes;
349
+ _bbWidth = decoded.width;
350
+ _bbHeight = decoded.height;
351
+ final image =
352
+ await _makeImage(decoded.bytes, decoded.width, decoded.height);
353
+ _swapDisplay(image);
354
+ return;
355
+ }
356
+
357
+ // Composite: matplotlib's diff PNG has alpha=0 for unchanged pixels.
358
+ // Where alpha != 0, copy the new pixel into the backbuffer.
359
+ final bb = _backbuffer!.buffer.asUint32List();
360
+ final df = decoded.bytes.buffer.asUint32List();
361
+ for (int i = 0; i < df.length; i++) {
362
+ // RGBA8888 on little-endian: alpha is the highest byte (0xFF000000).
363
+ if ((df[i] & 0xFF000000) != 0) {
364
+ bb[i] = df[i];
365
+ }
366
+ }
367
+
368
+ final image = await _makeImage(_backbuffer!, _bbWidth, _bbHeight);
369
+ _swapDisplay(image);
370
+ }
371
+
372
+ @override
373
+ Future<void> clearAll() async {
374
+ final old = _displayImage;
375
+ _displayImage = null;
376
+ _backbuffer = null;
377
+ _bbWidth = 0;
378
+ _bbHeight = 0;
379
+ if (old != null) {
380
+ WidgetsBinding.instance.addPostFrameCallback((_) {
381
+ old.dispose();
382
+ });
383
+ }
384
+ if (mounted) setState(() {});
385
+ }
386
+
387
+ Future<_DecodedRgba?> _decodeRgba(Uint8List bytes) async {
388
+ final img = await _decodeImage(bytes);
389
+ if (img == null) return null;
390
+ try {
391
+ final byteData =
392
+ await img.toByteData(format: ui.ImageByteFormat.rawRgba);
393
+ if (byteData == null) return null;
394
+ return _DecodedRgba(
395
+ bytes: byteData.buffer.asUint8List(),
396
+ width: img.width,
397
+ height: img.height,
398
+ );
399
+ } finally {
400
+ img.dispose();
401
+ }
402
+ }
403
+
404
+ Future<ui.Image> _makeImage(Uint8List rgba, int width, int height) {
405
+ final completer = Completer<ui.Image>();
406
+ ui.decodeImageFromPixels(
407
+ rgba,
408
+ width,
409
+ height,
410
+ ui.PixelFormat.rgba8888,
411
+ completer.complete,
412
+ );
413
+ return completer.future;
414
+ }
415
+
416
+ void _swapDisplay(ui.Image newImage) {
417
+ final old = _displayImage;
418
+ _displayImage = newImage;
419
+ if (mounted) setState(() {});
420
+ if (old != null) {
421
+ WidgetsBinding.instance.addPostFrameCallback((_) {
422
+ old.dispose();
423
+ });
424
+ }
425
+ }
426
+
427
+ @override
428
+ CustomPainter buildPainter() =>
429
+ _CpuMatplotlibImagePainter(image: _displayImage);
430
+ }
431
+
432
+ class _DecodedRgba {
433
+ final Uint8List bytes;
434
+ final int width;
435
+ final int height;
436
+ _DecodedRgba(
437
+ {required this.bytes, required this.width, required this.height});
438
+ }
439
+
440
+ class _CpuMatplotlibImagePainter extends CustomPainter {
441
+ final ui.Image? image;
442
+
443
+ const _CpuMatplotlibImagePainter({required this.image});
444
+
445
+ @override
446
+ void paint(Canvas canvas, Size size) {
447
+ final img = image;
448
+ if (img == null) return;
449
+ final src =
450
+ Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
451
+ final dst = Rect.fromLTWH(0, 0, size.width, size.height);
452
+ canvas.drawImageRect(img, src, dst, Paint());
453
+ }
454
+
455
+ @override
456
+ bool shouldRepaint(_CpuMatplotlibImagePainter old) => old.image != image;
457
+ }
@@ -1,54 +0,0 @@
1
- import asyncio
2
-
3
- from matplotlib import _api
4
- from matplotlib.backends import backend_webagg_core
5
-
6
-
7
- class TimerFletAsyncio(backend_webagg_core.TimerAsyncio):
8
- """Asyncio timer that's safe to start from a worker thread.
9
-
10
- Matplotlib's stock `TimerAsyncio._timer_start` calls
11
- `asyncio.ensure_future`, which requires the calling thread to have a
12
- current event loop. Flet's matplotlib chart runs `canvas.draw()` in a
13
- worker thread to keep the asyncio loop free for input events; that
14
- thread has no event loop. We capture the loop at construction time and
15
- schedule via `run_coroutine_threadsafe` when invoked from off-loop.
16
- """
17
-
18
- def __init__(self, *args, **kwargs):
19
- super().__init__(*args, **kwargs)
20
- try:
21
- self._loop = asyncio.get_running_loop()
22
- except RuntimeError:
23
- self._loop = asyncio.get_event_loop_policy().get_event_loop()
24
-
25
- def _timer_start(self):
26
- self._timer_stop()
27
- coro = self._timer_task(max(self.interval / 1_000.0, 1e-6))
28
- try:
29
- current = asyncio.get_running_loop()
30
- except RuntimeError:
31
- current = None
32
- if current is self._loop:
33
- self._task = self._loop.create_task(coro)
34
- else:
35
- self._task = asyncio.run_coroutine_threadsafe(coro, self._loop)
36
-
37
-
38
- class FigureCanvasFletAgg(backend_webagg_core.FigureCanvasWebAggCore):
39
- """Canvas implementation used to render Matplotlib figures in Flet."""
40
-
41
- manager_class = _api.classproperty(lambda cls: FigureManagerFletAgg)
42
- supports_blit = False
43
- _timer_cls = TimerFletAsyncio
44
-
45
-
46
- class FigureManagerFletAgg(backend_webagg_core.FigureManagerWebAgg):
47
- """Figure manager binding Matplotlib WebAgg tooling to Flet transport."""
48
-
49
- _toolbar2_class = backend_webagg_core.NavigationToolbar2WebAgg
50
-
51
-
52
- FigureCanvas = FigureCanvasFletAgg
53
- FigureManager = FigureManagerFletAgg
54
- interactive = True
@@ -1 +0,0 @@
1
- flet==0.85.0.dev3
@@ -1,276 +0,0 @@
1
- import 'dart:async';
2
- import 'dart:typed_data';
3
- import 'dart:ui' as ui;
4
-
5
- import 'package:flet/flet.dart';
6
- import 'package:flutter/material.dart';
7
-
8
- /// Display widget for matplotlib WebAgg-style image streams.
9
- ///
10
- /// Receives full and incremental "diff" PNG frames via control method calls
11
- /// and composites them in CPU memory. Holds at most one [ui.Image] for
12
- /// display at a time, replacing it on each apply. This avoids the per-frame
13
- /// `Picture.toImage` allocations that the generic Canvas+capture path uses,
14
- /// which on Flutter web (CanvasKit/WASM) accumulate and are not promptly
15
- /// reclaimed by the JS GC during animations.
16
- class MatplotlibChartCanvasControl extends StatefulWidget {
17
- final Control control;
18
-
19
- MatplotlibChartCanvasControl({Key? key, required this.control})
20
- : super(key: key ?? ValueKey("control_${control.id}"));
21
-
22
- @override
23
- State<MatplotlibChartCanvasControl> createState() =>
24
- _MatplotlibChartCanvasState();
25
- }
26
-
27
- class _MatplotlibChartCanvasState extends State<MatplotlibChartCanvasControl> {
28
- ui.Image? _displayImage;
29
- Uint8List? _backbuffer;
30
- int _bbWidth = 0;
31
- int _bbHeight = 0;
32
-
33
- // Serialize concurrent apply_full / apply_diff calls. Each invocation
34
- // awaits the previous one so the backbuffer mutations happen in order.
35
- Future<void>? _applyChain;
36
-
37
- Size _lastSize = Size.zero;
38
- int _lastResize = DateTime.now().millisecondsSinceEpoch;
39
-
40
- @override
41
- void initState() {
42
- super.initState();
43
- widget.control.addInvokeMethodListener(_invokeMethod);
44
- }
45
-
46
- @override
47
- void dispose() {
48
- widget.control.removeInvokeMethodListener(_invokeMethod);
49
- _displayImage?.dispose();
50
- _displayImage = null;
51
- _backbuffer = null;
52
- super.dispose();
53
- }
54
-
55
- Future<dynamic> _invokeMethod(String name, dynamic args) async {
56
- switch (name) {
57
- case "apply_full":
58
- await _enqueue(() => _applyFull(_extractBytes(args)));
59
- return;
60
- case "apply_diff":
61
- await _enqueue(() => _applyDiff(_extractBytes(args)));
62
- return;
63
- case "clear":
64
- await _enqueue(() async {
65
- _disposeDisplay();
66
- _backbuffer = null;
67
- _bbWidth = 0;
68
- _bbHeight = 0;
69
- if (mounted) setState(() {});
70
- });
71
- return;
72
- default:
73
- throw Exception("Unknown MatplotlibChartCanvas method: $name");
74
- }
75
- }
76
-
77
- Uint8List _extractBytes(dynamic args) {
78
- final v = args is Map ? args["bytes"] : args;
79
- if (v is Uint8List) return v;
80
- if (v is ByteData) {
81
- return v.buffer.asUint8List(v.offsetInBytes, v.lengthInBytes);
82
- }
83
- if (v is List<int>) return Uint8List.fromList(v);
84
- if (v is List && v.every((e) => e is int)) {
85
- return Uint8List.fromList(v.cast<int>());
86
- }
87
- throw ArgumentError("Expected bytes for image data, got ${v.runtimeType}");
88
- }
89
-
90
- // Chains apply operations so they run sequentially. Without this,
91
- // overlapping awaits could let a later diff be composited before an
92
- // earlier full frame finished decoding, producing tearing.
93
- Future<void> _enqueue(Future<void> Function() task) {
94
- final prev = _applyChain ?? Future.value();
95
- final next = prev.then((_) => task());
96
- _applyChain = next.catchError((_) {});
97
- return next;
98
- }
99
-
100
- Future<void> _applyFull(Uint8List bytes) async {
101
- final decoded = await _decodeRgba(bytes);
102
- if (decoded == null) return;
103
-
104
- _backbuffer = decoded.bytes;
105
- _bbWidth = decoded.width;
106
- _bbHeight = decoded.height;
107
-
108
- final image = await _makeImage(decoded.bytes, decoded.width, decoded.height);
109
- _swapDisplay(image);
110
- }
111
-
112
- Future<void> _applyDiff(Uint8List bytes) async {
113
- if (_backbuffer == null) {
114
- // No baseline yet — treat as full so we don't render a transparent
115
- // diff with no underlying frame.
116
- await _applyFull(bytes);
117
- return;
118
- }
119
-
120
- final decoded = await _decodeRgba(bytes);
121
- if (decoded == null) return;
122
-
123
- // Diffs from matplotlib are sized to the figure buffer. If the frame
124
- // size has changed since the last full frame (e.g. resize race),
125
- // promote to a full replace.
126
- if (decoded.width != _bbWidth ||
127
- decoded.height != _bbHeight ||
128
- decoded.bytes.length != _backbuffer!.length) {
129
- _backbuffer = decoded.bytes;
130
- _bbWidth = decoded.width;
131
- _bbHeight = decoded.height;
132
- final image =
133
- await _makeImage(decoded.bytes, decoded.width, decoded.height);
134
- _swapDisplay(image);
135
- return;
136
- }
137
-
138
- // Composite: matplotlib's diff PNG has alpha=0 for unchanged pixels.
139
- // Where alpha != 0, copy the new pixel into the backbuffer.
140
- final bb = _backbuffer!.buffer.asUint32List();
141
- final df = decoded.bytes.buffer.asUint32List();
142
- for (int i = 0; i < df.length; i++) {
143
- // RGBA8888 on little-endian: alpha is the highest byte (0xFF000000).
144
- if ((df[i] & 0xFF000000) != 0) {
145
- bb[i] = df[i];
146
- }
147
- }
148
-
149
- final image = await _makeImage(_backbuffer!, _bbWidth, _bbHeight);
150
- _swapDisplay(image);
151
- }
152
-
153
- Future<_DecodedRgba?> _decodeRgba(Uint8List bytes) async {
154
- if (bytes.isEmpty) {
155
- debugPrint("MatplotlibChartCanvas: skipping empty image bytes");
156
- return null;
157
- }
158
- // Take a defensive copy. msgpack_dart sometimes hands us a Uint8List
159
- // backed by a buffer that's reused/freed by Safari's WASM runtime,
160
- // causing CanvasKit's async decoder to throw "EncodingError: Loading
161
- // error." after the original buffer is gone.
162
- final owned = Uint8List.fromList(bytes);
163
- ui.Codec? codec;
164
- ui.Image? img;
165
- try {
166
- codec = await ui.instantiateImageCodec(owned, allowUpscaling: false);
167
- final frame = await codec.getNextFrame();
168
- img = frame.image;
169
- final byteData =
170
- await img.toByteData(format: ui.ImageByteFormat.rawRgba);
171
- if (byteData == null) return null;
172
- return _DecodedRgba(
173
- bytes: byteData.buffer.asUint8List(),
174
- width: img.width,
175
- height: img.height,
176
- );
177
- } catch (e) {
178
- debugPrint(
179
- "MatplotlibChartCanvas: decode failed (${owned.length} bytes): $e");
180
- rethrow;
181
- } finally {
182
- img?.dispose();
183
- codec?.dispose();
184
- }
185
- }
186
-
187
- Future<ui.Image> _makeImage(Uint8List rgba, int width, int height) {
188
- final completer = Completer<ui.Image>();
189
- ui.decodeImageFromPixels(
190
- rgba,
191
- width,
192
- height,
193
- ui.PixelFormat.rgba8888,
194
- completer.complete,
195
- );
196
- return completer.future;
197
- }
198
-
199
- void _swapDisplay(ui.Image newImage) {
200
- final old = _displayImage;
201
- _displayImage = newImage;
202
- if (mounted) setState(() {});
203
- if (old != null) {
204
- // Defer disposal to the next frame so any in-flight paint that still
205
- // references the old image completes first.
206
- WidgetsBinding.instance.addPostFrameCallback((_) {
207
- old.dispose();
208
- });
209
- }
210
- }
211
-
212
- void _disposeDisplay() {
213
- final old = _displayImage;
214
- _displayImage = null;
215
- if (old != null) {
216
- WidgetsBinding.instance.addPostFrameCallback((_) {
217
- old.dispose();
218
- });
219
- }
220
- }
221
-
222
- void _maybeReportResize(Size size) {
223
- final resizeInterval = widget.control.getInt("resize_interval", 10)!;
224
- final now = DateTime.now().millisecondsSinceEpoch;
225
- if ((now - _lastResize > resizeInterval && _lastSize != size) ||
226
- _lastSize.isEmpty) {
227
- _lastSize = size;
228
- _lastResize = now;
229
- widget.control.triggerEvent("resize", {"w": size.width, "h": size.height});
230
- }
231
- }
232
-
233
- @override
234
- Widget build(BuildContext context) {
235
- return LayoutBuilder(
236
- builder: (context, constraints) {
237
- // Fire on_resize on layout. matplotlib uses this to know the target
238
- // figure size.
239
- WidgetsBinding.instance.addPostFrameCallback((_) {
240
- if (!mounted) return;
241
- _maybeReportResize(constraints.biggest);
242
- });
243
- return CustomPaint(
244
- size: constraints.biggest,
245
- painter: _MatplotlibImagePainter(_displayImage),
246
- );
247
- },
248
- );
249
- }
250
- }
251
-
252
- class _DecodedRgba {
253
- final Uint8List bytes;
254
- final int width;
255
- final int height;
256
- _DecodedRgba({required this.bytes, required this.width, required this.height});
257
- }
258
-
259
- class _MatplotlibImagePainter extends CustomPainter {
260
- final ui.Image? image;
261
-
262
- _MatplotlibImagePainter(this.image);
263
-
264
- @override
265
- void paint(Canvas canvas, Size size) {
266
- final img = image;
267
- if (img == null) return;
268
- final src =
269
- Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
270
- final dst = Rect.fromLTWH(0, 0, size.width, size.height);
271
- canvas.drawImageRect(img, src, dst, Paint());
272
- }
273
-
274
- @override
275
- bool shouldRepaint(_MatplotlibImagePainter old) => old.image != image;
276
- }