From 25583036170fc3a4cb9de01a8a32af7b7986a5a0 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 16 Jul 2025 18:44:16 -0700 Subject: [PATCH 01/16] MPL v2 draft --- .../matplotlib_backends/backend_flet_agg.py | 16 ++ src/flet_charts/matplotlib_chart.py | 179 ++++++++++++++---- tests/mpl_v2_basic.py | 26 +++ 3 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 src/flet_charts/matplotlib_backends/backend_flet_agg.py create mode 100644 tests/mpl_v2_basic.py diff --git a/src/flet_charts/matplotlib_backends/backend_flet_agg.py b/src/flet_charts/matplotlib_backends/backend_flet_agg.py new file mode 100644 index 0000000..8e2cc30 --- /dev/null +++ b/src/flet_charts/matplotlib_backends/backend_flet_agg.py @@ -0,0 +1,16 @@ +from matplotlib import _api +from matplotlib.backends import backend_webagg_core + + +class FigureCanvasFletAgg(backend_webagg_core.FigureCanvasWebAggCore): + manager_class = _api.classproperty(lambda cls: FigureManagerFletAgg) + supports_blit = False + + +class FigureManagerFletAgg(backend_webagg_core.FigureManagerWebAgg): + _toolbar2_class = backend_webagg_core.NavigationToolbar2WebAgg + + +FigureCanvas = FigureCanvasFletAgg +FigureManager = FigureManagerFletAgg +interactive = True diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index e7bfbde..7e96282 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -1,9 +1,11 @@ -import io -import re -import xml.etree.ElementTree as ET +import asyncio +import logging from dataclasses import field import flet as ft +import flet.canvas as fc + +logging.basicConfig(level=logging.INFO) try: from matplotlib.figure import Figure @@ -16,12 +18,13 @@ @ft.control(kw_only=True) -class MatplotlibChart(ft.Container): +class MatplotlibChart(ft.GestureDetector): """ Displays a [Matplotlib](https://matplotlib.org/) chart. Warning: - This control requires the [`matplotlib`](https://matplotlib.org/) Python package to be installed. + This control requires the [`matplotlib`](https://matplotlib.org/) Python package + to be installed. See this [installation guide](index.md#installation) for more information. """ @@ -32,33 +35,145 @@ class MatplotlibChart(ft.Container): [`matplotlib.figure.Figure`](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure). """ - original_size: bool = False - """ - Whether to display chart in original size. + def init(self): + # self.on_resize = self.on_canvas_resize + # self.shapes = [fc.Line(x1=0, y1=0, x2=50, y2=50)] + self.mouse_cursor = ft.MouseCursor.WAIT + self.__started = False + self.__dpr = self.page.media.device_pixel_ratio + print("DPR:", self.__dpr) + self.__image_mode = "full" - Set to `False` to display a chart that fits configured bounds. - """ + self.canvas = fc.Canvas( + # resize_interval=10, + on_resize=self.on_canvas_resize, + expand=True, + ) + self.content = self.canvas + self.on_pan_start = self._pan_start + self.on_pan_update = self._pan_update + self.on_pan_end = self._pan_end + self.img_count = 1 + self._receive_queue = asyncio.Queue() + self._main_loop = asyncio.get_event_loop() + self._width = 0 + self._height = 0 + self._waiting = False - transparent: bool = False - """ - Whether to remove the background from the chart. - """ + # def before_update(self): + # super().before_update() - def init(self): - self.alignment = ft.Alignment.center() - self.__img = ft.Image(fit=ft.BoxFit.FILL) - self.content = self.__img - - def before_update(self): - super().before_update() - if self.figure is not None: - s = io.StringIO() - self.figure.savefig(s, format="svg", transparent=self.transparent) - svg = s.getvalue() - - if not self.original_size: - root = ET.fromstring(svg) - w = float(re.findall(r"\d+", root.attrib["width"])[0]) - h = float(re.findall(r"\d+", root.attrib["height"])[0]) - self.__img.aspect_ratio = w / h - self.__img.src = svg + def _pan_start(self, e: ft.DragStartEvent): + # print("MPL._pan_start:", e.local_x, e.local_y) + self.send_message( + { + "type": "button_press", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 1, + "modifiers": [], + } + ) + + def _pan_update(self, e: ft.DragUpdateEvent): + # print("MPL._pan_update:", e.local_x, e.local_y) + self.send_message( + { + "type": "motion_notify", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 1, + "modifiers": [], + } + ) + + def _pan_end(self, e: ft.DragEndEvent): + # print("MPL._pan_end:", e.local_x, e.local_y) + self.send_message( + { + "type": "button_release", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def will_unmount(self): + self.figure.canvas.manager.remove_web_socket(self) + + def pan(self): + print("MPL.pan()") + self.send_message({"type": "toolbar_button", "name": "pan"}) + + async def _receive_loop(self): + while True: + is_binary, content = await self._receive_queue.get() + if is_binary: + print(f"MPL.receive_binary({len(content)})") + if self.__image_mode == "full": + await self.canvas.clear_capture_async() + + self.canvas.shapes = [ + fc.Image( + src_bytes=content, + x=0, + y=0, + width=self.figure.bbox.size[0] / self.__dpr, + height=self.figure.bbox.size[1] / self.__dpr, + ) + ] + self.update() + await self.canvas.capture_async() + self.img_count += 1 + self._waiting = False + else: + print(f"MPL.receive_json({content})") + if content["type"] == "image_mode": + self.__image_mode = content["mode"] + elif content["type"] == "draw" and not self._waiting: + self._waiting = True + await self.send_message_async({"type": "draw"}) + elif content["type"] == "resize": + self.send_message({"type": "refresh"}) + + async def send_message_async(self, message): + print(f"MPL.send_message_async({message})") + manager = self.figure.canvas.manager + if manager is not None: + await asyncio.to_thread(manager.handle_json, message) + + def send_message(self, message): + print(f"MPL.send_message({message})") + manager = self.figure.canvas.manager + if manager is not None: + manager.handle_json(message) + + def send_json(self, content): + self._main_loop.call_soon_threadsafe( + lambda: self._receive_queue.put_nowait((False, content)) + ) + + def send_binary(self, blob): + self._main_loop.call_soon_threadsafe( + lambda: self._receive_queue.put_nowait((True, blob)) + ) + + async def on_canvas_resize(self, e: fc.CanvasResizeEvent): + print("on_canvas_resize:", e.width, e.height) + + if not self.__started: + self.__started = True + asyncio.create_task(self._receive_loop()) + self.figure.canvas.manager.add_web_socket(self) + self.send_message({"type": "send_image_mode"}) + self.send_message( + {"type": "set_device_pixel_ratio", "device_pixel_ratio": self.__dpr} + ) + self.send_message({"type": "refresh"}) + self._width = e.width + self._height = e.height + self.send_message({"type": "resize", "width": e.width, "height": e.height}) diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py new file mode 100644 index 0000000..433e0c0 --- /dev/null +++ b/tests/mpl_v2_basic.py @@ -0,0 +1,26 @@ +import flet as ft +import matplotlib +import matplotlib.pyplot as plt + +import flet_charts + +matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") + + +def main(page: ft.Page): + # Create first figure + fig1, ax1 = plt.subplots() + ax1.plot([0, 1], [0, 1], label="Line 1") + ax1.text(0.5, 0.5, "Hello Flutter 1", fontsize=12) + ax1.legend() + + mpl = flet_charts.MatplotlibChart(figure=fig1, expand=True) + + # fig1.canvas.start() + page.add( + ft.Row([ft.Button("Pan", on_click=lambda: mpl.pan())]), + mpl, + ) + + +ft.run(main) From 65e8ec6dc338c2f7a9f44c1646371333604da95e Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 17 Jul 2025 11:34:36 -0700 Subject: [PATCH 02/16] Added toml for tests --- .gitignore | 3 ++- pyproject.toml | 2 +- src/flet_charts/bar_chart.py | 2 +- tests/pyproject.toml | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 tests/pyproject.toml diff --git a/.gitignore b/.gitignore index 006e58f..6f1796d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ build/ develop-eggs/ dist/ .DS_store -.venv/ \ No newline at end of file +.venv/ +tests/uv.lock diff --git a/pyproject.toml b/pyproject.toml index a4c2d35..81d6fa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [{ name = "Flet contributors", email = "hello@flet.dev" }] license = "Apache-2.0" requires-python = ">=3.10" dependencies = [ - "flet >=0.70.0.dev0", + "flet", ] [project.urls] diff --git a/src/flet_charts/bar_chart.py b/src/flet_charts/bar_chart.py index 419d1bc..2c68935 100644 --- a/src/flet_charts/bar_chart.py +++ b/src/flet_charts/bar_chart.py @@ -225,7 +225,7 @@ class BarChart(ft.ConstrainedControl): The tooltip configuration for the chart. """ - on_event: ft.OptionalEventHandler[BarChartEvent["BarChart"]] = None + on_event: ft.OptionalEventHandler[BarChartEvent] = None """ Fires when a bar is hovered or clicked. diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..25ffc4c --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "flet-charts-tests" +version = "1.0.0" +description = "flet-charts-tests" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "flet-charts", + "flet", +] + +# Docs: https://flet.dev/docs/publish +[tool.flet] +org = "com.mycompany" +product = "flet-charts-tests" +company = "Flet" +copyright = "Copyright (C) 2024 by Flet" + +[tool.flet.dev_packages] +flet-charts = "../" + +[tool.uv] +dev-dependencies = [ + "flet-cli", + "flet-desktop", + "flet-web", + "matplotlib>=3.10.3", + "plotly>=6.2.0", +] + +[tool.uv.sources] +flet-charts = { path = "../", editable = true } +flet = { path = "../../flet/sdk/python/packages/flet", editable = true } +flet-cli = { path = "../../flet/sdk/python/packages/flet-cli", editable = true } +flet-desktop = { path = "../../flet/sdk/python/packages/flet-desktop", editable = true } +flet-web = { path = "../../flet/sdk/python/packages/flet-web", editable = true } From ac6cfc8ecdcbe244df73be342189b32425a41b85 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 17 Jul 2025 16:11:28 -0700 Subject: [PATCH 03/16] Cursors, rubberband support --- src/flet_charts/matplotlib_chart.py | 90 ++++++++++++++++++++++++++++- tests/mpl_v2_basic.py | 8 ++- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 7e96282..939d867 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -16,6 +16,15 @@ __all__ = ["MatplotlibChart"] +figure_cursors = { + "default": None, + "pointer": ft.MouseCursor.CLICK, + "crosshair": ft.MouseCursor.PRECISE, + "move": ft.MouseCursor.MOVE, + "wait": ft.MouseCursor.WAIT, + "ew-resize": ft.MouseCursor.RESIZE_LEFT_RIGHT, + "ns-resize": ft.MouseCursor.RESIZE_UP_DOWN +} @ft.control(kw_only=True) class MatplotlibChart(ft.GestureDetector): @@ -50,6 +59,9 @@ def init(self): expand=True, ) self.content = self.canvas + self.on_enter = self._on_enter + self.on_hover = self._on_hover + self.on_exit = self._on_exit self.on_pan_start = self._pan_start self.on_pan_update = self._pan_update self.on_pan_end = self._pan_end @@ -63,6 +75,45 @@ def init(self): # def before_update(self): # super().before_update() + def _on_enter(self, e: ft.HoverEvent): + # print("MPL._on_enter:", e.local_x, e.local_y) + self.send_message( + { + "type": "figure_enter", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def _on_hover(self, e: ft.HoverEvent): + # print("MPL._on_hover:", e.local_x, e.local_y) + self.send_message( + { + "type": "motion_notify", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def _on_exit(self, e: ft.HoverEvent): + # print("MPL._on_exit:", e.local_x, e.local_y) + self.send_message( + { + "type": "figure_leave", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + def _pan_start(self, e: ft.DragStartEvent): # print("MPL._pan_start:", e.local_x, e.local_y) self.send_message( @@ -105,10 +156,26 @@ def _pan_end(self, e: ft.DragEndEvent): def will_unmount(self): self.figure.canvas.manager.remove_web_socket(self) + def home(self): + print("MPL.home)") + self.send_message({"type": "toolbar_button", "name": "home"}) + + def back(self): + print("MPL.back()") + self.send_message({"type": "toolbar_button", "name": "back"}) + + def forward(self): + print("MPL.forward)") + self.send_message({"type": "toolbar_button", "name": "forward"}) + def pan(self): print("MPL.pan()") self.send_message({"type": "toolbar_button", "name": "pan"}) + def zoom(self): + print("MPL.zoom()") + self.send_message({"type": "toolbar_button", "name": "zoom"}) + async def _receive_loop(self): while True: is_binary, content = await self._receive_queue.get() @@ -126,7 +193,7 @@ async def _receive_loop(self): height=self.figure.bbox.size[1] / self.__dpr, ) ] - self.update() + self.canvas.update() await self.canvas.capture_async() self.img_count += 1 self._waiting = False @@ -134,9 +201,30 @@ async def _receive_loop(self): print(f"MPL.receive_json({content})") if content["type"] == "image_mode": self.__image_mode = content["mode"] + elif content["type"] == "cursor": + self.mouse_cursor = figure_cursors[content["cursor"]] + self.update() elif content["type"] == "draw" and not self._waiting: self._waiting = True await self.send_message_async({"type": "draw"}) + elif content["type"] == "rubberband": + if content["x0"] == -1 and content["y0"] == -1 and content["x1"] == -1 and content["y1"] == -1: + if len(self.canvas.shapes) == 2: + self.canvas.shapes.pop() + self.canvas.update() + else: + x0 = content["x0"] / self.__dpr + y0 = self._height - content["y0"] / self.__dpr + x1 = content["x1"] / self.__dpr + y1 = self._height - content["y1"] / self.__dpr + rubberband_rect = self.canvas.shapes.pop() if len(self.canvas.shapes) == 2 else fc.Rect(paint=ft.Paint(stroke_width=1, style=ft.PaintingStyle.STROKE)) + rubberband_rect.x = x0 + rubberband_rect.y = y0 + rubberband_rect.width = x1 - x0 + rubberband_rect.height = y1 - y0 + print("RUBBERBAND_RECT:", rubberband_rect) + self.canvas.shapes.append(rubberband_rect) + self.canvas.update() elif content["type"] == "resize": self.send_message({"type": "refresh"}) diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 433e0c0..58f9844 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -18,7 +18,13 @@ def main(page: ft.Page): # fig1.canvas.start() page.add( - ft.Row([ft.Button("Pan", on_click=lambda: mpl.pan())]), + ft.Row([ + ft.Button("Home", on_click=lambda: mpl.home()), + ft.Button("Back", on_click=lambda: mpl.back()), + ft.Button("Forward", on_click=lambda: mpl.forward()), + ft.Button("Pan", on_click=lambda: mpl.pan()), + ft.Button("Zoom", on_click=lambda: mpl.zoom()) + ]), mpl, ) From cf28a38384b011a6f31e49fe945053ec4471e6eb Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 17 Jul 2025 16:52:04 -0700 Subject: [PATCH 04/16] MPL.on_message event --- src/flet_charts/__init__.py | 2 +- src/flet_charts/line_chart.py | 4 ++-- src/flet_charts/matplotlib_chart.py | 18 ++++++++++++++++-- tests/mpl_v2_basic.py | 9 +++++++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/flet_charts/__init__.py b/src/flet_charts/__init__.py index 3809081..bacf890 100644 --- a/src/flet_charts/__init__.py +++ b/src/flet_charts/__init__.py @@ -16,7 +16,7 @@ ) from .line_chart_data import LineChartData from .line_chart_data_point import LineChartDataPoint, LineChartDataPointTooltip -from .matplotlib_chart import MatplotlibChart +from .matplotlib_chart import MatplotlibChart, MatplotlibChartMessageEvent from .pie_chart import PieChart, PieChartEvent from .pie_chart_section import PieChartSection from .plotly_chart import PlotlyChart diff --git a/src/flet_charts/line_chart.py b/src/flet_charts/line_chart.py index 57a1304..8f51660 100644 --- a/src/flet_charts/line_chart.py +++ b/src/flet_charts/line_chart.py @@ -29,7 +29,7 @@ class LineChartEventSpot: @dataclass -class LineChartEvent(ft.Event[ft.EventControlType]): +class LineChartEvent(ft.Event["LineChart"]): type: ChartEventType """ The type of event that occured. @@ -238,7 +238,7 @@ class LineChart(ft.ConstrainedControl): The tooltip configuration for this chart. """ - on_event: ft.OptionalEventHandler[LineChartEvent["LineChart"]] = None + on_event: ft.OptionalEventHandler[LineChartEvent] = None """ Fires when a chart line is hovered or clicked. diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 939d867..277c821 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -1,6 +1,6 @@ import asyncio import logging -from dataclasses import field +from dataclasses import dataclass, field import flet as ft import flet.canvas as fc @@ -14,7 +14,7 @@ 'Install "matplotlib" Python package to use MatplotlibChart control.' ) from e -__all__ = ["MatplotlibChart"] +__all__ = ["MatplotlibChart", "MatplotlibChartMessageEvent"] figure_cursors = { "default": None, @@ -26,6 +26,13 @@ "ns-resize": ft.MouseCursor.RESIZE_UP_DOWN } +@dataclass +class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]): + message: str + """ + Message text. + """ + @ft.control(kw_only=True) class MatplotlibChart(ft.GestureDetector): """ @@ -44,6 +51,11 @@ class MatplotlibChart(ft.GestureDetector): [`matplotlib.figure.Figure`](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure). """ + on_message: ft.OptionalEventHandler[MatplotlibChartMessageEvent] = None + """ + The event is triggered on figure message update. + """ + def init(self): # self.on_resize = self.on_canvas_resize # self.shapes = [fc.Line(x1=0, y1=0, x2=50, y2=50)] @@ -227,6 +239,8 @@ async def _receive_loop(self): self.canvas.update() elif content["type"] == "resize": self.send_message({"type": "refresh"}) + elif content["type"] == "message": + await self._trigger_event("message", {"message": content["message"]}) async def send_message_async(self, message): print(f"MPL.send_message_async({message})") diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 58f9844..9442364 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -14,7 +14,11 @@ def main(page: ft.Page): ax1.text(0.5, 0.5, "Hello Flutter 1", fontsize=12) ax1.legend() - mpl = flet_charts.MatplotlibChart(figure=fig1, expand=True) + msg = ft.Text() + def on_message(e: flet_charts.MatplotlibChartMessageEvent): + msg.value = e.message + + mpl = flet_charts.MatplotlibChart(figure=fig1, expand=True, on_message=on_message) # fig1.canvas.start() page.add( @@ -23,7 +27,8 @@ def main(page: ft.Page): ft.Button("Back", on_click=lambda: mpl.back()), ft.Button("Forward", on_click=lambda: mpl.forward()), ft.Button("Pan", on_click=lambda: mpl.pan()), - ft.Button("Zoom", on_click=lambda: mpl.zoom()) + ft.Button("Zoom", on_click=lambda: mpl.zoom()), + msg ]), mpl, ) From 4bad36b335a97ba304500cc3ae02d69da24596ad Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 17 Jul 2025 19:59:14 -0700 Subject: [PATCH 05/16] Toolbar add, no Download yet --- src/flet_charts/__init__.py | 2 +- src/flet_charts/matplotlib_chart.py | 20 ++++++++++- tests/mpl_v2_basic.py | 52 ++++++++++++++++++++++------- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/flet_charts/__init__.py b/src/flet_charts/__init__.py index bacf890..c9fac1b 100644 --- a/src/flet_charts/__init__.py +++ b/src/flet_charts/__init__.py @@ -16,7 +16,7 @@ ) from .line_chart_data import LineChartData from .line_chart_data_point import LineChartDataPoint, LineChartDataPointTooltip -from .matplotlib_chart import MatplotlibChart, MatplotlibChartMessageEvent +from .matplotlib_chart import MatplotlibChart, MatplotlibChartMessageEvent, MatplotlibChartToolbarButtonsUpdateEvent from .pie_chart import PieChart, PieChartEvent from .pie_chart_section import PieChartSection from .plotly_chart import PlotlyChart diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 277c821..2cadf54 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -14,7 +14,7 @@ 'Install "matplotlib" Python package to use MatplotlibChart control.' ) from e -__all__ = ["MatplotlibChart", "MatplotlibChartMessageEvent"] +__all__ = ["MatplotlibChart", "MatplotlibChartMessageEvent", "MatplotlibChartToolbarButtonsUpdateEvent"] figure_cursors = { "default": None, @@ -33,6 +33,17 @@ class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]): Message text. """ +@dataclass +class MatplotlibChartToolbarButtonsUpdateEvent(ft.Event["MatplotlibChart"]): + back_enabled: bool + """ + Whether Back button is enabled or not. + """ + forward_enabled: bool + """ + Whether Forward button is enabled or not. + """ + @ft.control(kw_only=True) class MatplotlibChart(ft.GestureDetector): """ @@ -56,6 +67,11 @@ class MatplotlibChart(ft.GestureDetector): The event is triggered on figure message update. """ + on_toolbar_buttons_update: ft.OptionalEventHandler[MatplotlibChartToolbarButtonsUpdateEvent] = None + """ + Triggers when toolbar buttons status is updated. + """ + def init(self): # self.on_resize = self.on_canvas_resize # self.shapes = [fc.Line(x1=0, y1=0, x2=50, y2=50)] @@ -241,6 +257,8 @@ async def _receive_loop(self): self.send_message({"type": "refresh"}) elif content["type"] == "message": await self._trigger_event("message", {"message": content["message"]}) + elif content["type"] == "history_buttons": + await self._trigger_event("toolbar_buttons_update", {"back_enabled": content["Back"],"forward_enabled": content["Forward"]}) async def send_message_async(self, message): print(f"MPL.send_message_async({message})") diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 9442364..a803223 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -1,33 +1,61 @@ +import logging import flet as ft import matplotlib import matplotlib.pyplot as plt - +import numpy as np import flet_charts matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") +logging.basicConfig(level=logging.DEBUG) def main(page: ft.Page): - # Create first figure - fig1, ax1 = plt.subplots() - ax1.plot([0, 1], [0, 1], label="Line 1") - ax1.text(0.5, 0.5, "Hello Flutter 1", fontsize=12) - ax1.legend() + # Sample data + x = np.linspace(0, 10, 100) + y = np.sin(x) + + # Plot + fig = plt.figure() + print("Figure number:", fig.number) + plt.plot(x, y) + plt.title("Interactive Sine Wave") + plt.xlabel("X axis") + plt.ylabel("Y axis") + plt.grid(True) msg = ft.Text() def on_message(e: flet_charts.MatplotlibChartMessageEvent): msg.value = e.message - mpl = flet_charts.MatplotlibChart(figure=fig1, expand=True, on_message=on_message) + def on_toolbar_update(e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent): + back_btn.disabled = not e.back_enabled + fwd_btn.disabled = not e.forward_enabled + + def pan_click(): + mpl.pan() + pan_btn.selected = not pan_btn.selected + zoom_btn.selected = False + + def zoom_click(): + mpl.zoom() + pan_btn.selected = False + zoom_btn.selected = not zoom_btn.selected + + def download_click(): + pass + + mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) # fig1.canvas.start() page.add( ft.Row([ - ft.Button("Home", on_click=lambda: mpl.home()), - ft.Button("Back", on_click=lambda: mpl.back()), - ft.Button("Forward", on_click=lambda: mpl.forward()), - ft.Button("Pan", on_click=lambda: mpl.pan()), - ft.Button("Zoom", on_click=lambda: mpl.zoom()), + ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), + back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), + fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), + pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), + zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), + ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), + ft.Dropdown(value="png", options=[ft.DropdownOption("png"), ft.DropdownOption("pdf"), ft.DropdownOption("svg")]), msg ]), mpl, From 732242d7b4c5b2f203b13abcd7ca78e9b6f4dfaa Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 20 Jul 2025 18:26:37 -0700 Subject: [PATCH 06/16] Right-click handle, download, keyboard draft --- src/flet_charts/matplotlib_chart.py | 61 +++++++++++++++++++++++++++-- tests/mpl_v2_basic.py | 26 ++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 2cadf54..1f7b444 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -1,12 +1,11 @@ import asyncio +from io import BytesIO import logging from dataclasses import dataclass, field import flet as ft import flet.canvas as fc -logging.basicConfig(level=logging.INFO) - try: from matplotlib.figure import Figure except ImportError as e: @@ -86,13 +85,17 @@ def init(self): on_resize=self.on_canvas_resize, expand=True, ) - self.content = self.canvas + self.keyboard_listener = ft.KeyboardListener(self.canvas, autofocus=True, on_key_down=self._on_key_down, on_key_up=self._on_key_up) + self.content = self.keyboard_listener self.on_enter = self._on_enter self.on_hover = self._on_hover self.on_exit = self._on_exit self.on_pan_start = self._pan_start self.on_pan_update = self._pan_update self.on_pan_end = self._pan_end + self.on_right_pan_start = self._right_pan_start + self.on_right_pan_update = self._right_pan_update + self.on_right_pan_end = self._right_pan_end self.img_count = 1 self._receive_queue = asyncio.Queue() self._main_loop = asyncio.get_event_loop() @@ -103,6 +106,12 @@ def init(self): # def before_update(self): # super().before_update() + def _on_key_down(self, e): + print("ON KEY DOWN:", e) + + def _on_key_up(self, e): + print("ON KEY UP:", e) + def _on_enter(self, e: ft.HoverEvent): # print("MPL._on_enter:", e.local_x, e.local_y) self.send_message( @@ -144,6 +153,7 @@ def _on_exit(self, e: ft.HoverEvent): def _pan_start(self, e: ft.DragStartEvent): # print("MPL._pan_start:", e.local_x, e.local_y) + self.keyboard_listener.focus() self.send_message( { "type": "button_press", @@ -181,6 +191,45 @@ def _pan_end(self, e: ft.DragEndEvent): } ) + def _right_pan_start(self, e: ft.DragStartEvent): + # print("MPL._pan_start:", e.local_x, e.local_y) + self.send_message( + { + "type": "button_press", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 2, + "buttons": 2, + "modifiers": [], + } + ) + + def _right_pan_update(self, e: ft.DragUpdateEvent): + # print("MPL._pan_update:", e.local_x, e.local_y) + self.send_message( + { + "type": "motion_notify", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 0, + "buttons": 2, + "modifiers": [], + } + ) + + def _right_pan_end(self, e: ft.DragEndEvent): + # print("MPL._pan_end:", e.local_x, e.local_y) + self.send_message( + { + "type": "button_release", + "x": e.local_x * self.__dpr, + "y": e.local_y * self.__dpr, + "button": 2, + "buttons": 0, + "modifiers": [], + } + ) + def will_unmount(self): self.figure.canvas.manager.remove_web_socket(self) @@ -204,6 +253,12 @@ def zoom(self): print("MPL.zoom()") self.send_message({"type": "toolbar_button", "name": "zoom"}) + def download(self, format): + print("Download in format:", format) + buff = BytesIO() + self.figure.savefig(buff, format=format) + return buff.getvalue() + async def _receive_loop(self): while True: is_binary, content = await self._receive_queue.get() diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index a803223..0fc1d48 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -1,3 +1,4 @@ +from io import BytesIO import logging import flet as ft import matplotlib @@ -23,6 +24,22 @@ def main(page: ft.Page): plt.ylabel("Y axis") plt.grid(True) + download_formats = [ + "eps", + "jpeg", + "pgf", + "pdf", + "png", + "ps", + "raw", + "svg", + "tif", + "webp" + ] + + fp = ft.FilePicker() + page.services.append(fp) + msg = ft.Text() def on_message(e: flet_charts.MatplotlibChartMessageEvent): msg.value = e.message @@ -41,8 +58,11 @@ def zoom_click(): pan_btn.selected = False zoom_btn.selected = not zoom_btn.selected - def download_click(): - pass + async def download_click(): + fmt = dwnld_fmt.value + buffer = mpl.download(fmt) + title = fig.canvas.manager.get_window_title() + await fp.save_file_async(file_name=f"{title}.{fmt}", src_bytes=buffer) mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) @@ -55,7 +75,7 @@ def download_click(): pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - ft.Dropdown(value="png", options=[ft.DropdownOption("png"), ft.DropdownOption("pdf"), ft.DropdownOption("svg")]), + dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), msg ]), mpl, From 7319a3c0272a760e9e1f1518b83183f9ff7699bd Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 20 Jul 2025 19:43:36 -0700 Subject: [PATCH 07/16] Some cleanup, animation and event samples --- src/flet_charts/matplotlib_chart.py | 59 +++++----- tests/mpl_v2_animate.py | 118 ++++++++++++++++++++ tests/mpl_v2_basic.py | 26 ++++- tests/mpl_v2_events.py | 163 ++++++++++++++++++++++++++++ 4 files changed, 333 insertions(+), 33 deletions(-) create mode 100644 tests/mpl_v2_animate.py create mode 100644 tests/mpl_v2_events.py diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 1f7b444..bf7d93f 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -15,6 +15,8 @@ __all__ = ["MatplotlibChart", "MatplotlibChartMessageEvent", "MatplotlibChartToolbarButtonsUpdateEvent"] +logger = logging.getLogger("flet-charts.matplotlib") + figure_cursors = { "default": None, "pointer": ft.MouseCursor.CLICK, @@ -77,7 +79,7 @@ def init(self): self.mouse_cursor = ft.MouseCursor.WAIT self.__started = False self.__dpr = self.page.media.device_pixel_ratio - print("DPR:", self.__dpr) + logger.debug(f"DPR: {self.__dpr}") self.__image_mode = "full" self.canvas = fc.Canvas( @@ -107,13 +109,13 @@ def init(self): # super().before_update() def _on_key_down(self, e): - print("ON KEY DOWN:", e) + logger.debug(f"ON KEY DOWN: {e}") def _on_key_up(self, e): - print("ON KEY UP:", e) + logger.debug(f"ON KEY UP: {e}") def _on_enter(self, e: ft.HoverEvent): - # print("MPL._on_enter:", e.local_x, e.local_y) + logger.debug(f"_on_enter: {e.local_x}, {e.local_y}") self.send_message( { "type": "figure_enter", @@ -126,7 +128,7 @@ def _on_enter(self, e: ft.HoverEvent): ) def _on_hover(self, e: ft.HoverEvent): - # print("MPL._on_hover:", e.local_x, e.local_y) + logger.debug(f"_on_hover: {e.local_x}, {e.local_y}") self.send_message( { "type": "motion_notify", @@ -139,7 +141,7 @@ def _on_hover(self, e: ft.HoverEvent): ) def _on_exit(self, e: ft.HoverEvent): - # print("MPL._on_exit:", e.local_x, e.local_y) + logger.debug(f"_on_exit: {e.local_x}, {e.local_y}") self.send_message( { "type": "figure_leave", @@ -152,7 +154,7 @@ def _on_exit(self, e: ft.HoverEvent): ) def _pan_start(self, e: ft.DragStartEvent): - # print("MPL._pan_start:", e.local_x, e.local_y) + logger.debug(f"_pan_start: {e.local_x}, {e.local_y}") self.keyboard_listener.focus() self.send_message( { @@ -161,12 +163,12 @@ def _pan_start(self, e: ft.DragStartEvent): "y": e.local_y * self.__dpr, "button": 0, "buttons": 1, - "modifiers": [], + "modifiers": [] } ) def _pan_update(self, e: ft.DragUpdateEvent): - # print("MPL._pan_update:", e.local_x, e.local_y) + logger.debug(f"_pan_update: {e.local_x}, {e.local_y}") self.send_message( { "type": "motion_notify", @@ -179,7 +181,7 @@ def _pan_update(self, e: ft.DragUpdateEvent): ) def _pan_end(self, e: ft.DragEndEvent): - # print("MPL._pan_end:", e.local_x, e.local_y) + logger.debug(f"_pan_end: {e.local_x}, {e.local_y}") self.send_message( { "type": "button_release", @@ -192,7 +194,7 @@ def _pan_end(self, e: ft.DragEndEvent): ) def _right_pan_start(self, e: ft.DragStartEvent): - # print("MPL._pan_start:", e.local_x, e.local_y) + logger.debug(f"_pan_start: {e.local_x}, {e.local_y}") self.send_message( { "type": "button_press", @@ -205,7 +207,7 @@ def _right_pan_start(self, e: ft.DragStartEvent): ) def _right_pan_update(self, e: ft.DragUpdateEvent): - # print("MPL._pan_update:", e.local_x, e.local_y) + logger.debug(f"_pan_update: {e.local_x}, {e.local_y}") self.send_message( { "type": "motion_notify", @@ -218,7 +220,7 @@ def _right_pan_update(self, e: ft.DragUpdateEvent): ) def _right_pan_end(self, e: ft.DragEndEvent): - # print("MPL._pan_end:", e.local_x, e.local_y) + logger.debug(f"_pan_end: {e.local_x}, {e.local_y}") self.send_message( { "type": "button_release", @@ -234,36 +236,36 @@ def will_unmount(self): self.figure.canvas.manager.remove_web_socket(self) def home(self): - print("MPL.home)") + logger.debug("home)") self.send_message({"type": "toolbar_button", "name": "home"}) def back(self): - print("MPL.back()") + logger.debug("back()") self.send_message({"type": "toolbar_button", "name": "back"}) def forward(self): - print("MPL.forward)") + logger.debug("forward)") self.send_message({"type": "toolbar_button", "name": "forward"}) def pan(self): - print("MPL.pan()") + logger.debug("pan()") self.send_message({"type": "toolbar_button", "name": "pan"}) def zoom(self): - print("MPL.zoom()") + logger.debug("zoom()") self.send_message({"type": "toolbar_button", "name": "zoom"}) def download(self, format): - print("Download in format:", format) + logger.debug(f"Download in format: {format}") buff = BytesIO() - self.figure.savefig(buff, format=format) + self.figure.savefig(buff, format=format, dpi=self.figure.dpi * self.__dpr) return buff.getvalue() async def _receive_loop(self): while True: is_binary, content = await self._receive_queue.get() if is_binary: - print(f"MPL.receive_binary({len(content)})") + logger.debug(f"receive_binary({len(content)})") if self.__image_mode == "full": await self.canvas.clear_capture_async() @@ -281,7 +283,7 @@ async def _receive_loop(self): self.img_count += 1 self._waiting = False else: - print(f"MPL.receive_json({content})") + logger.debug(f"receive_json({content})") if content["type"] == "image_mode": self.__image_mode = content["mode"] elif content["type"] == "cursor": @@ -289,7 +291,7 @@ async def _receive_loop(self): self.update() elif content["type"] == "draw" and not self._waiting: self._waiting = True - await self.send_message_async({"type": "draw"}) + self.send_message({"type": "draw"}) elif content["type"] == "rubberband": if content["x0"] == -1 and content["y0"] == -1 and content["x1"] == -1 and content["y1"] == -1: if len(self.canvas.shapes) == 2: @@ -305,7 +307,6 @@ async def _receive_loop(self): rubberband_rect.y = y0 rubberband_rect.width = x1 - x0 rubberband_rect.height = y1 - y0 - print("RUBBERBAND_RECT:", rubberband_rect) self.canvas.shapes.append(rubberband_rect) self.canvas.update() elif content["type"] == "resize": @@ -315,14 +316,8 @@ async def _receive_loop(self): elif content["type"] == "history_buttons": await self._trigger_event("toolbar_buttons_update", {"back_enabled": content["Back"],"forward_enabled": content["Forward"]}) - async def send_message_async(self, message): - print(f"MPL.send_message_async({message})") - manager = self.figure.canvas.manager - if manager is not None: - await asyncio.to_thread(manager.handle_json, message) - def send_message(self, message): - print(f"MPL.send_message({message})") + logger.debug(f"send_message({message})") manager = self.figure.canvas.manager if manager is not None: manager.handle_json(message) @@ -338,7 +333,7 @@ def send_binary(self, blob): ) async def on_canvas_resize(self, e: fc.CanvasResizeEvent): - print("on_canvas_resize:", e.width, e.height) + logger.debug(f"on_canvas_resize: {e.width}, {e.height}") if not self.__started: self.__started = True diff --git a/tests/mpl_v2_animate.py b/tests/mpl_v2_animate.py new file mode 100644 index 0000000..170f251 --- /dev/null +++ b/tests/mpl_v2_animate.py @@ -0,0 +1,118 @@ +import functools +from io import BytesIO +import logging +import flet as ft +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +import flet_charts + +matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") + +logging.basicConfig(level=logging.INFO) + +state = {} + +def main(page: ft.Page): + + import matplotlib.animation as animation + + # Fixing random state for reproducibility + np.random.seed(19680801) + + + def random_walk(num_steps, max_step=0.05): + """Return a 3D random walk as (num_steps, 3) array.""" + start_pos = np.random.random(3) + steps = np.random.uniform(-max_step, max_step, size=(num_steps, 3)) + walk = start_pos + np.cumsum(steps, axis=0) + return walk + + + def update_lines(num, walks, lines): + for line, walk in zip(lines, walks): + line.set_data_3d(walk[:num, :].T) + return lines + + + # Data: 40 random walks as (num_steps, 3) arrays + num_steps = 30 + walks = [random_walk(num_steps) for index in range(40)] + + # Attaching 3D axis to the figure + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + + # Create lines initially without data + lines = [ax.plot([], [], [])[0] for _ in walks] + + # Setting the Axes properties + ax.set(xlim3d=(0, 1), xlabel='X') + ax.set(ylim3d=(0, 1), ylabel='Y') + ax.set(zlim3d=(0, 1), zlabel='Z') + + # Creating the Animation object + state["anim"] = animation.FuncAnimation( + fig, update_lines, num_steps, fargs=(walks, lines), interval=100) + + plt.show() + + download_formats = [ + "eps", + "jpeg", + "pgf", + "pdf", + "png", + "ps", + "raw", + "svg", + "tif", + "webp" + ] + + fp = ft.FilePicker() + page.services.append(fp) + + msg = ft.Text() + def on_message(e: flet_charts.MatplotlibChartMessageEvent): + msg.value = e.message + + def on_toolbar_update(e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent): + back_btn.disabled = not e.back_enabled + fwd_btn.disabled = not e.forward_enabled + + def pan_click(): + mpl.pan() + pan_btn.selected = not pan_btn.selected + zoom_btn.selected = False + + def zoom_click(): + mpl.zoom() + pan_btn.selected = False + zoom_btn.selected = not zoom_btn.selected + + async def download_click(): + fmt = dwnld_fmt.value + buffer = mpl.download(fmt) + title = fig.canvas.manager.get_window_title() + await fp.save_file_async(file_name=f"{title}.{fmt}", src_bytes=buffer) + + mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) + + # fig1.canvas.start() + page.add( + ft.Row([ + ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), + back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), + fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), + pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), + zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), + ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), + dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), + msg + ]), + mpl, + ) + + +ft.run(main) diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 0fc1d48..4cdda9d 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -8,7 +8,7 @@ matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) def main(page: ft.Page): # Sample data @@ -24,6 +24,30 @@ def main(page: ft.Page): plt.ylabel("Y axis") plt.grid(True) + # plt.style.use('_mpl-gallery') + + # # Make data for a double helix + # n = 50 + # theta = np.linspace(0, 2*np.pi, n) + # x1 = np.cos(theta) + # y1 = np.sin(theta) + # z1 = np.linspace(0, 1, n) + # x2 = np.cos(theta + np.pi) + # y2 = np.sin(theta + np.pi) + # z2 = z1 + + # # Plot + # fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + # ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) + # ax.plot(x1, y1, z1, linewidth=2, color='C0') + # ax.plot(x2, y2, z2, linewidth=2, color='C0') + + # ax.set(xticklabels=[], + # yticklabels=[], + # zticklabels=[]) + + # plt.show() + download_formats = [ "eps", "jpeg", diff --git a/tests/mpl_v2_events.py b/tests/mpl_v2_events.py new file mode 100644 index 0000000..885f0f6 --- /dev/null +++ b/tests/mpl_v2_events.py @@ -0,0 +1,163 @@ +from io import BytesIO +import logging +import flet as ft +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +import flet_charts + +matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") + +#logging.basicConfig(level=logging.DEBUG) + +state = {} + +def main(page: ft.Page): + + # Fixing random state for reproducibility + np.random.seed(19680801) + + X = np.random.rand(100, 200) + xs = np.mean(X, axis=1) + ys = np.std(X, axis=1) + + fig, (ax, ax2) = plt.subplots(2, 1) + ax.set_title('click on point to plot time series') + line, = ax.plot(xs, ys, 'o', picker=True, pickradius=5) + + class PointBrowser: + """ + Click on a point to select and highlight it -- the data that + generated the point will be shown in the lower Axes. Use the 'n' + and 'p' keys to browse through the next and previous points + """ + + def __init__(self): + self.lastind = 0 + + self.text = ax.text(0.05, 0.95, 'selected: none', + transform=ax.transAxes, va='top') + self.selected, = ax.plot([xs[0]], [ys[0]], 'o', ms=12, alpha=0.4, + color='yellow', visible=False) + + def on_press(self, event): + if self.lastind is None: + return + if event.key not in ('n', 'p'): + return + if event.key == 'n': + inc = 1 + else: + inc = -1 + + self.lastind += inc + self.lastind = np.clip(self.lastind, 0, len(xs) - 1) + self.update() + + def on_pick(self, event): + + print("ON PICK") + + if event.artist != line: + return True + + N = len(event.ind) + if not N: + return True + + # the click locations + x = event.mouseevent.xdata + y = event.mouseevent.ydata + + distances = np.hypot(x - xs[event.ind], y - ys[event.ind]) + indmin = distances.argmin() + dataind = event.ind[indmin] + + self.lastind = dataind + self.update() + + def update(self): + if self.lastind is None: + return + + dataind = self.lastind + + ax2.clear() + ax2.plot(X[dataind]) + + ax2.text(0.05, 0.9, f'mu={xs[dataind]:1.3f}\nsigma={ys[dataind]:1.3f}', + transform=ax2.transAxes, va='top') + ax2.set_ylim(-0.5, 1.5) + self.selected.set_visible(True) + self.selected.set_data([xs[dataind]], [ys[dataind]]) + + self.text.set_text('selected: %d' % dataind) + fig.canvas.draw() + + browser = PointBrowser() + state["browser"] = browser + + fig.canvas.mpl_connect('pick_event', browser.on_pick) + fig.canvas.mpl_connect('key_press_event', browser.on_press) + + plt.show() + + download_formats = [ + "eps", + "jpeg", + "pgf", + "pdf", + "png", + "ps", + "raw", + "svg", + "tif", + "webp" + ] + + fp = ft.FilePicker() + page.services.append(fp) + + msg = ft.Text() + def on_message(e: flet_charts.MatplotlibChartMessageEvent): + msg.value = e.message + + def on_toolbar_update(e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent): + back_btn.disabled = not e.back_enabled + fwd_btn.disabled = not e.forward_enabled + + def pan_click(): + mpl.pan() + pan_btn.selected = not pan_btn.selected + zoom_btn.selected = False + + def zoom_click(): + mpl.zoom() + pan_btn.selected = False + zoom_btn.selected = not zoom_btn.selected + + async def download_click(): + fmt = dwnld_fmt.value + buffer = mpl.download(fmt) + title = fig.canvas.manager.get_window_title() + await fp.save_file_async(file_name=f"{title}.{fmt}", src_bytes=buffer) + + mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) + + # fig1.canvas.start() + page.add( + ft.Row([ + ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), + back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), + fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), + pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), + zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), + ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), + dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), + msg + ]), + mpl, + ) + + +ft.run(main) From a853a5dfa44bbd35b657d2dac56e2e1d90299e04 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 5 Aug 2025 15:53:08 -0700 Subject: [PATCH 08/16] Get rid of OptionalEventHandler --- src/flet_charts/bar_chart.py | 2 +- src/flet_charts/line_chart.py | 2 +- src/flet_charts/matplotlib_chart.py | 76 +++++++++++++---------------- src/flet_charts/pie_chart.py | 2 +- src/flet_charts/scatter_chart.py | 2 +- 5 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/flet_charts/bar_chart.py b/src/flet_charts/bar_chart.py index 2c68935..f74afe5 100644 --- a/src/flet_charts/bar_chart.py +++ b/src/flet_charts/bar_chart.py @@ -225,7 +225,7 @@ class BarChart(ft.ConstrainedControl): The tooltip configuration for the chart. """ - on_event: ft.OptionalEventHandler[BarChartEvent] = None + on_event: Optional[ft.EventHandler[BarChartEvent]] = None """ Fires when a bar is hovered or clicked. diff --git a/src/flet_charts/line_chart.py b/src/flet_charts/line_chart.py index 8f51660..0e21ee4 100644 --- a/src/flet_charts/line_chart.py +++ b/src/flet_charts/line_chart.py @@ -238,7 +238,7 @@ class LineChart(ft.ConstrainedControl): The tooltip configuration for this chart. """ - on_event: ft.OptionalEventHandler[LineChartEvent] = None + on_event: Optional[ft.EventHandler[LineChartEvent]] = None """ Fires when a chart line is hovered or clicked. diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index bf7d93f..de9ad04 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -2,6 +2,7 @@ from io import BytesIO import logging from dataclasses import dataclass, field +from typing import Optional import flet as ft import flet.canvas as fc @@ -63,12 +64,12 @@ class MatplotlibChart(ft.GestureDetector): [`matplotlib.figure.Figure`](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure). """ - on_message: ft.OptionalEventHandler[MatplotlibChartMessageEvent] = None + on_message: Optional[ft.EventHandler[MatplotlibChartMessageEvent]] = None """ The event is triggered on figure message update. """ - on_toolbar_buttons_update: ft.OptionalEventHandler[MatplotlibChartToolbarButtonsUpdateEvent] = None + on_toolbar_buttons_update: Optional[ft.EventHandler[MatplotlibChartToolbarButtonsUpdateEvent]] = None """ Triggers when toolbar buttons status is updated. """ @@ -115,12 +116,12 @@ def _on_key_up(self, e): logger.debug(f"ON KEY UP: {e}") def _on_enter(self, e: ft.HoverEvent): - logger.debug(f"_on_enter: {e.local_x}, {e.local_y}") + logger.debug(f"_on_enter: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "figure_enter", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 0, "modifiers": [], @@ -128,12 +129,12 @@ def _on_enter(self, e: ft.HoverEvent): ) def _on_hover(self, e: ft.HoverEvent): - logger.debug(f"_on_hover: {e.local_x}, {e.local_y}") + logger.debug(f"_on_hover: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "motion_notify", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 0, "modifiers": [], @@ -141,12 +142,12 @@ def _on_hover(self, e: ft.HoverEvent): ) def _on_exit(self, e: ft.HoverEvent): - logger.debug(f"_on_exit: {e.local_x}, {e.local_y}") + logger.debug(f"_on_exit: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "figure_leave", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 0, "modifiers": [], @@ -154,13 +155,13 @@ def _on_exit(self, e: ft.HoverEvent): ) def _pan_start(self, e: ft.DragStartEvent): - logger.debug(f"_pan_start: {e.local_x}, {e.local_y}") + logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}") self.keyboard_listener.focus() self.send_message( { "type": "button_press", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 1, "modifiers": [] @@ -168,12 +169,12 @@ def _pan_start(self, e: ft.DragStartEvent): ) def _pan_update(self, e: ft.DragUpdateEvent): - logger.debug(f"_pan_update: {e.local_x}, {e.local_y}") + logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "motion_notify", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 1, "modifiers": [], @@ -181,12 +182,12 @@ def _pan_update(self, e: ft.DragUpdateEvent): ) def _pan_end(self, e: ft.DragEndEvent): - logger.debug(f"_pan_end: {e.local_x}, {e.local_y}") + logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "button_release", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 0, "modifiers": [], @@ -194,12 +195,12 @@ def _pan_end(self, e: ft.DragEndEvent): ) def _right_pan_start(self, e: ft.DragStartEvent): - logger.debug(f"_pan_start: {e.local_x}, {e.local_y}") + logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "button_press", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 2, "buttons": 2, "modifiers": [], @@ -207,12 +208,12 @@ def _right_pan_start(self, e: ft.DragStartEvent): ) def _right_pan_update(self, e: ft.DragUpdateEvent): - logger.debug(f"_pan_update: {e.local_x}, {e.local_y}") + logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "motion_notify", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 2, "modifiers": [], @@ -220,12 +221,12 @@ def _right_pan_update(self, e: ft.DragUpdateEvent): ) def _right_pan_end(self, e: ft.DragEndEvent): - logger.debug(f"_pan_end: {e.local_x}, {e.local_y}") + logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}") self.send_message( { "type": "button_release", - "x": e.local_x * self.__dpr, - "y": e.local_y * self.__dpr, + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, "button": 2, "buttons": 0, "modifiers": [], @@ -293,22 +294,15 @@ async def _receive_loop(self): self._waiting = True self.send_message({"type": "draw"}) elif content["type"] == "rubberband": - if content["x0"] == -1 and content["y0"] == -1 and content["x1"] == -1 and content["y1"] == -1: - if len(self.canvas.shapes) == 2: - self.canvas.shapes.pop() - self.canvas.update() - else: + if len(self.canvas.shapes) == 2: + self.canvas.shapes.pop() + if content["x0"] != -1 and content["y0"] != -1 and content["x1"] != -1 and content["y1"] != -1: x0 = content["x0"] / self.__dpr y0 = self._height - content["y0"] / self.__dpr x1 = content["x1"] / self.__dpr y1 = self._height - content["y1"] / self.__dpr - rubberband_rect = self.canvas.shapes.pop() if len(self.canvas.shapes) == 2 else fc.Rect(paint=ft.Paint(stroke_width=1, style=ft.PaintingStyle.STROKE)) - rubberband_rect.x = x0 - rubberband_rect.y = y0 - rubberband_rect.width = x1 - x0 - rubberband_rect.height = y1 - y0 - self.canvas.shapes.append(rubberband_rect) - self.canvas.update() + self.canvas.shapes.append(fc.Rect(x=x0, y=y0, width=x1 - x0, height = y1 - y0, paint=ft.Paint(stroke_width=1, style=ft.PaintingStyle.STROKE))) + self.canvas.update() elif content["type"] == "resize": self.send_message({"type": "refresh"}) elif content["type"] == "message": diff --git a/src/flet_charts/pie_chart.py b/src/flet_charts/pie_chart.py index 511c1d0..1bbe55a 100644 --- a/src/flet_charts/pie_chart.py +++ b/src/flet_charts/pie_chart.py @@ -81,7 +81,7 @@ class PieChart(ft.ConstrainedControl): Value is of type [`AnimationValue`](https://flet.dev/docs/reference/types/animationvalue). """ - on_event: ft.OptionalEventHandler[PieChartEvent["PieChart"]] = None + on_event: Optional[ft.EventHandler[PieChartEvent["PieChart"]]] = None """ Fires when a chart section is hovered or clicked. diff --git a/src/flet_charts/scatter_chart.py b/src/flet_charts/scatter_chart.py index 8fb2e53..ea56834 100644 --- a/src/flet_charts/scatter_chart.py +++ b/src/flet_charts/scatter_chart.py @@ -206,7 +206,7 @@ class ScatterChart(ft.ConstrainedControl): The tooltip configuration for the chart. """ - on_event: ft.OptionalEventHandler[ScatterChartEvent["ScatterChart"]] = None + on_event: Optional[ft.EventHandler[ScatterChartEvent["ScatterChart"]]] = None """ Fires when an event occurs on the chart. From 4c985c45461cb5131c118fd8e89abc706e876d8d Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 20 Aug 2025 12:08:13 -0700 Subject: [PATCH 09/16] Update method name and toolbar icons Renamed 'init' to 'build' in MatplotlibChart for clarity. Changed pan and zoom toolbar icons in tests to use OPEN_WITH and SEARCH for better representation. --- src/flet_charts/matplotlib_chart.py | 2 +- tests/mpl_v2_events.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index de9ad04..630ae1d 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -74,7 +74,7 @@ class MatplotlibChart(ft.GestureDetector): Triggers when toolbar buttons status is updated. """ - def init(self): + def build(self): # self.on_resize = self.on_canvas_resize # self.shapes = [fc.Line(x1=0, y1=0, x2=50, y2=50)] self.mouse_cursor = ft.MouseCursor.WAIT diff --git a/tests/mpl_v2_events.py b/tests/mpl_v2_events.py index 885f0f6..3adf986 100644 --- a/tests/mpl_v2_events.py +++ b/tests/mpl_v2_events.py @@ -150,8 +150,8 @@ async def download_click(): ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), - pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), - zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), + pan_btn := ft.IconButton(ft.Icons.OPEN_WITH, selected_icon=ft.Icons.OPEN_WITH, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), + zoom_btn := ft.IconButton(ft.Icons.SEARCH, selected_icon=ft.Icons.SEARCH, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), msg From 5eb61db1a6324ba4d28188cfa7c65e944a804f75 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 24 Aug 2025 10:35:28 -0700 Subject: [PATCH 10/16] Expose MatplotlibChart event classes in __init__ Added MatplotlibChartMessageEvent and MatplotlibChartToolbarButtonsUpdateEvent to the public API in __init__.py. Also replaced async canvas capture methods with their synchronous counterparts in MatplotlibChart for image handling. --- src/flet_charts/__init__.py | 8 +++++++- src/flet_charts/matplotlib_chart.py | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/flet_charts/__init__.py b/src/flet_charts/__init__.py index 1bdde40..b35b20e 100644 --- a/src/flet_charts/__init__.py +++ b/src/flet_charts/__init__.py @@ -19,7 +19,11 @@ LineChartDataPoint, LineChartDataPointTooltip, ) -from flet_charts.matplotlib_chart import MatplotlibChart +from flet_charts.matplotlib_chart import ( + MatplotlibChart, + MatplotlibChartMessageEvent, + MatplotlibChartToolbarButtonsUpdateEvent, +) from flet_charts.pie_chart import PieChart, PieChartEvent from flet_charts.pie_chart_section import PieChartSection from flet_charts.plotly_chart import PlotlyChart @@ -78,4 +82,6 @@ "ScatterChartSpot", "ScatterChartSpotTooltip", "ScatterChartTooltip", + "MatplotlibChartMessageEvent", + "MatplotlibChartToolbarButtonsUpdateEvent", ] diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 43c43fc..6ba8e8b 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -268,7 +268,7 @@ async def _receive_loop(self): if is_binary: logger.debug(f"receive_binary({len(content)})") if self.__image_mode == "full": - await self.canvas.clear_capture_async() + await self.canvas.clear_capture() self.canvas.shapes = [ fc.Image( @@ -280,7 +280,7 @@ async def _receive_loop(self): ) ] self.canvas.update() - await self.canvas.capture_async() + await self.canvas.capture() self.img_count += 1 self._waiting = False else: From f7ffafd1edc5a711b29c91b28c2881a290f2af56 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 1 Oct 2025 10:49:08 -0700 Subject: [PATCH 11/16] Refactor MatplotlibChart and update test formatting Refactored src/flet_charts/matplotlib_chart.py for improved readability, async focus handling, and event argument types. Updated test files to use consistent formatting, removed unused imports, and replaced deprecated FilePicker API usage. Enhanced code clarity and maintainability across chart and test modules. --- src/flet_charts/matplotlib_chart.py | 70 +++++++++++++++++----- tests/mpl_v2_animate.py | 70 ++++++++++++++-------- tests/mpl_v2_basic.py | 56 ++++++++++++----- tests/mpl_v2_events.py | 93 +++++++++++++++++++---------- 4 files changed, 202 insertions(+), 87 deletions(-) diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 6ba8e8b..66afbc7 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -1,7 +1,7 @@ import asyncio -from io import BytesIO import logging from dataclasses import dataclass, field +from io import BytesIO from typing import Optional import flet as ft @@ -14,7 +14,11 @@ 'Install "matplotlib" Python package to use MatplotlibChart control.' ) from e -__all__ = ["MatplotlibChart", "MatplotlibChartMessageEvent", "MatplotlibChartToolbarButtonsUpdateEvent"] +__all__ = [ + "MatplotlibChart", + "MatplotlibChartMessageEvent", + "MatplotlibChartToolbarButtonsUpdateEvent", +] logger = logging.getLogger("flet-charts.matplotlib") @@ -25,18 +29,20 @@ "move": ft.MouseCursor.MOVE, "wait": ft.MouseCursor.WAIT, "ew-resize": ft.MouseCursor.RESIZE_LEFT_RIGHT, - "ns-resize": ft.MouseCursor.RESIZE_UP_DOWN + "ns-resize": ft.MouseCursor.RESIZE_UP_DOWN, } + @dataclass -class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]): +class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]): message: str """ Message text. """ + @dataclass -class MatplotlibChartToolbarButtonsUpdateEvent(ft.Event["MatplotlibChart"]): +class MatplotlibChartToolbarButtonsUpdateEvent(ft.Event["MatplotlibChart"]): back_enabled: bool """ Whether Back button is enabled or not. @@ -46,6 +52,7 @@ class MatplotlibChartToolbarButtonsUpdateEvent(ft.Event["MatplotlibChart"]): Whether Forward button is enabled or not. """ + @ft.control(kw_only=True) class MatplotlibChart(ft.GestureDetector): """ @@ -69,7 +76,9 @@ class MatplotlibChart(ft.GestureDetector): The event is triggered on figure message update. """ - on_toolbar_buttons_update: Optional[ft.EventHandler[MatplotlibChartToolbarButtonsUpdateEvent]] = None + on_toolbar_buttons_update: Optional[ + ft.EventHandler[MatplotlibChartToolbarButtonsUpdateEvent] + ] = None """ Triggers when toolbar buttons status is updated. """ @@ -88,7 +97,12 @@ def build(self): on_resize=self.on_canvas_resize, expand=True, ) - self.keyboard_listener = ft.KeyboardListener(self.canvas, autofocus=True, on_key_down=self._on_key_down, on_key_up=self._on_key_up) + self.keyboard_listener = ft.KeyboardListener( + self.canvas, + autofocus=True, + on_key_down=self._on_key_down, + on_key_up=self._on_key_up, + ) self.content = self.keyboard_listener self.on_enter = self._on_enter self.on_hover = self._on_hover @@ -156,7 +170,7 @@ def _on_exit(self, e: ft.HoverEvent): def _pan_start(self, e: ft.DragStartEvent): logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}") - self.keyboard_listener.focus() + asyncio.create_task(self.keyboard_listener.focus()) self.send_message( { "type": "button_press", @@ -164,7 +178,7 @@ def _pan_start(self, e: ft.DragStartEvent): "y": e.local_position.y * self.__dpr, "button": 0, "buttons": 1, - "modifiers": [] + "modifiers": [], } ) @@ -194,7 +208,7 @@ def _pan_end(self, e: ft.DragEndEvent): } ) - def _right_pan_start(self, e: ft.DragStartEvent): + def _right_pan_start(self, e: ft.PointerEvent): logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}") self.send_message( { @@ -207,7 +221,7 @@ def _right_pan_start(self, e: ft.DragStartEvent): } ) - def _right_pan_update(self, e: ft.DragUpdateEvent): + def _right_pan_update(self, e: ft.PointerEvent): logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}") self.send_message( { @@ -220,7 +234,7 @@ def _right_pan_update(self, e: ft.DragUpdateEvent): } ) - def _right_pan_end(self, e: ft.DragEndEvent): + def _right_pan_end(self, e: ft.PointerEvent): logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}") self.send_message( { @@ -279,6 +293,7 @@ async def _receive_loop(self): height=self.figure.bbox.size[1] / self.__dpr, ) ] + ft.context.disable_auto_update() self.canvas.update() await self.canvas.capture() self.img_count += 1 @@ -296,19 +311,42 @@ async def _receive_loop(self): elif content["type"] == "rubberband": if len(self.canvas.shapes) == 2: self.canvas.shapes.pop() - if content["x0"] != -1 and content["y0"] != -1 and content["x1"] != -1 and content["y1"] != -1: + if ( + content["x0"] != -1 + and content["y0"] != -1 + and content["x1"] != -1 + and content["y1"] != -1 + ): x0 = content["x0"] / self.__dpr y0 = self._height - content["y0"] / self.__dpr x1 = content["x1"] / self.__dpr y1 = self._height - content["y1"] / self.__dpr - self.canvas.shapes.append(fc.Rect(x=x0, y=y0, width=x1 - x0, height = y1 - y0, paint=ft.Paint(stroke_width=1, style=ft.PaintingStyle.STROKE))) + self.canvas.shapes.append( + fc.Rect( + x=x0, + y=y0, + width=x1 - x0, + height=y1 - y0, + paint=ft.Paint( + stroke_width=1, style=ft.PaintingStyle.STROKE + ), + ) + ) self.canvas.update() elif content["type"] == "resize": self.send_message({"type": "refresh"}) elif content["type"] == "message": - await self._trigger_event("message", {"message": content["message"]}) + await self._trigger_event( + "message", {"message": content["message"]} + ) elif content["type"] == "history_buttons": - await self._trigger_event("toolbar_buttons_update", {"back_enabled": content["Back"],"forward_enabled": content["Forward"]}) + await self._trigger_event( + "toolbar_buttons_update", + { + "back_enabled": content["Back"], + "forward_enabled": content["Forward"], + }, + ) def send_message(self, message): logger.debug(f"send_message({message})") diff --git a/tests/mpl_v2_animate.py b/tests/mpl_v2_animate.py index 170f251..d2b2f94 100644 --- a/tests/mpl_v2_animate.py +++ b/tests/mpl_v2_animate.py @@ -1,10 +1,10 @@ -import functools -from io import BytesIO import logging + import flet as ft import matplotlib import matplotlib.pyplot as plt import numpy as np + import flet_charts matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") @@ -13,14 +13,13 @@ state = {} -def main(page: ft.Page): +def main(page: ft.Page): import matplotlib.animation as animation # Fixing random state for reproducibility np.random.seed(19680801) - def random_walk(num_steps, max_step=0.05): """Return a 3D random walk as (num_steps, 3) array.""" start_pos = np.random.random(3) @@ -28,13 +27,11 @@ def random_walk(num_steps, max_step=0.05): walk = start_pos + np.cumsum(steps, axis=0) return walk - def update_lines(num, walks, lines): for line, walk in zip(lines, walks): line.set_data_3d(walk[:num, :].T) return lines - # Data: 40 random walks as (num_steps, 3) arrays num_steps = 30 walks = [random_walk(num_steps) for index in range(40)] @@ -47,13 +44,14 @@ def update_lines(num, walks, lines): lines = [ax.plot([], [], [])[0] for _ in walks] # Setting the Axes properties - ax.set(xlim3d=(0, 1), xlabel='X') - ax.set(ylim3d=(0, 1), ylabel='Y') - ax.set(zlim3d=(0, 1), zlabel='Z') + ax.set(xlim3d=(0, 1), xlabel="X") + ax.set(ylim3d=(0, 1), ylabel="Y") + ax.set(zlim3d=(0, 1), zlabel="Z") # Creating the Animation object state["anim"] = animation.FuncAnimation( - fig, update_lines, num_steps, fargs=(walks, lines), interval=100) + fig, update_lines, num_steps, fargs=(walks, lines), interval=100 + ) plt.show() @@ -67,13 +65,13 @@ def update_lines(num, walks, lines): "raw", "svg", "tif", - "webp" + "webp", ] fp = ft.FilePicker() - page.services.append(fp) msg = ft.Text() + def on_message(e: flet_charts.MatplotlibChartMessageEvent): msg.value = e.message @@ -95,22 +93,46 @@ async def download_click(): fmt = dwnld_fmt.value buffer = mpl.download(fmt) title = fig.canvas.manager.get_window_title() - await fp.save_file_async(file_name=f"{title}.{fmt}", src_bytes=buffer) + await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) - mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) + mpl = flet_charts.MatplotlibChart( + figure=fig, + expand=True, + on_message=on_message, + on_toolbar_buttons_update=on_toolbar_update, + ) # fig1.canvas.start() page.add( - ft.Row([ - ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), - back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), - fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), - pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), - zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), - ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), - msg - ]), + ft.Row( + [ + ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), + back_btn := ft.IconButton( + ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back() + ), + fwd_btn := ft.IconButton( + ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward() + ), + pan_btn := ft.IconButton( + ft.Icons.PAN_TOOL_OUTLINED, + selected_icon=ft.Icons.PAN_TOOL_OUTLINED, + selected_icon_color=ft.Colors.AMBER_800, + on_click=pan_click, + ), + zoom_btn := ft.IconButton( + ft.Icons.ZOOM_IN, + selected_icon=ft.Icons.ZOOM_IN, + selected_icon_color=ft.Colors.AMBER_800, + on_click=zoom_click, + ), + ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), + dwnld_fmt := ft.Dropdown( + value="png", + options=[ft.DropdownOption(fmt) for fmt in download_formats], + ), + msg, + ] + ), mpl, ) diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 4cdda9d..72c4ed4 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -1,15 +1,17 @@ -from io import BytesIO import logging + import flet as ft import matplotlib import matplotlib.pyplot as plt import numpy as np + import flet_charts matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") logging.basicConfig(level=logging.INFO) + def main(page: ft.Page): # Sample data x = np.linspace(0, 10, 100) @@ -58,13 +60,13 @@ def main(page: ft.Page): "raw", "svg", "tif", - "webp" + "webp", ] fp = ft.FilePicker() - page.services.append(fp) msg = ft.Text() + def on_message(e: flet_charts.MatplotlibChartMessageEvent): msg.value = e.message @@ -86,22 +88,46 @@ async def download_click(): fmt = dwnld_fmt.value buffer = mpl.download(fmt) title = fig.canvas.manager.get_window_title() - await fp.save_file_async(file_name=f"{title}.{fmt}", src_bytes=buffer) + await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) - mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) + mpl = flet_charts.MatplotlibChart( + figure=fig, + expand=True, + on_message=on_message, + on_toolbar_buttons_update=on_toolbar_update, + ) # fig1.canvas.start() page.add( - ft.Row([ - ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), - back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), - fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), - pan_btn := ft.IconButton(ft.Icons.PAN_TOOL_OUTLINED, selected_icon=ft.Icons.PAN_TOOL_OUTLINED, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), - zoom_btn := ft.IconButton(ft.Icons.ZOOM_IN, selected_icon=ft.Icons.ZOOM_IN, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), - ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), - msg - ]), + ft.Row( + [ + ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), + back_btn := ft.IconButton( + ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back() + ), + fwd_btn := ft.IconButton( + ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward() + ), + pan_btn := ft.IconButton( + ft.Icons.PAN_TOOL_OUTLINED, + selected_icon=ft.Icons.PAN_TOOL_OUTLINED, + selected_icon_color=ft.Colors.AMBER_800, + on_click=pan_click, + ), + zoom_btn := ft.IconButton( + ft.Icons.ZOOM_IN, + selected_icon=ft.Icons.ZOOM_IN, + selected_icon_color=ft.Colors.AMBER_800, + on_click=zoom_click, + ), + ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), + dwnld_fmt := ft.Dropdown( + value="png", + options=[ft.DropdownOption(fmt) for fmt in download_formats], + ), + msg, + ] + ), mpl, ) diff --git a/tests/mpl_v2_events.py b/tests/mpl_v2_events.py index 3adf986..c09ff83 100644 --- a/tests/mpl_v2_events.py +++ b/tests/mpl_v2_events.py @@ -1,19 +1,18 @@ -from io import BytesIO -import logging import flet as ft import matplotlib import matplotlib.pyplot as plt import numpy as np + import flet_charts matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") -#logging.basicConfig(level=logging.DEBUG) +# logging.basicConfig(level=logging.DEBUG) state = {} -def main(page: ft.Page): +def main(page: ft.Page): # Fixing random state for reproducibility np.random.seed(19680801) @@ -22,8 +21,8 @@ def main(page: ft.Page): ys = np.std(X, axis=1) fig, (ax, ax2) = plt.subplots(2, 1) - ax.set_title('click on point to plot time series') - line, = ax.plot(xs, ys, 'o', picker=True, pickradius=5) + ax.set_title("click on point to plot time series") + (line,) = ax.plot(xs, ys, "o", picker=True, pickradius=5) class PointBrowser: """ @@ -35,17 +34,19 @@ class PointBrowser: def __init__(self): self.lastind = 0 - self.text = ax.text(0.05, 0.95, 'selected: none', - transform=ax.transAxes, va='top') - self.selected, = ax.plot([xs[0]], [ys[0]], 'o', ms=12, alpha=0.4, - color='yellow', visible=False) + self.text = ax.text( + 0.05, 0.95, "selected: none", transform=ax.transAxes, va="top" + ) + (self.selected,) = ax.plot( + [xs[0]], [ys[0]], "o", ms=12, alpha=0.4, color="yellow", visible=False + ) def on_press(self, event): if self.lastind is None: return - if event.key not in ('n', 'p'): + if event.key not in ("n", "p"): return - if event.key == 'n': + if event.key == "n": inc = 1 else: inc = -1 @@ -55,7 +56,6 @@ def on_press(self, event): self.update() def on_pick(self, event): - print("ON PICK") if event.artist != line: @@ -85,20 +85,25 @@ def update(self): ax2.clear() ax2.plot(X[dataind]) - ax2.text(0.05, 0.9, f'mu={xs[dataind]:1.3f}\nsigma={ys[dataind]:1.3f}', - transform=ax2.transAxes, va='top') + ax2.text( + 0.05, + 0.9, + f"mu={xs[dataind]:1.3f}\nsigma={ys[dataind]:1.3f}", + transform=ax2.transAxes, + va="top", + ) ax2.set_ylim(-0.5, 1.5) self.selected.set_visible(True) self.selected.set_data([xs[dataind]], [ys[dataind]]) - self.text.set_text('selected: %d' % dataind) + self.text.set_text("selected: %d" % dataind) fig.canvas.draw() browser = PointBrowser() state["browser"] = browser - fig.canvas.mpl_connect('pick_event', browser.on_pick) - fig.canvas.mpl_connect('key_press_event', browser.on_press) + fig.canvas.mpl_connect("pick_event", browser.on_pick) + fig.canvas.mpl_connect("key_press_event", browser.on_press) plt.show() @@ -112,13 +117,13 @@ def update(self): "raw", "svg", "tif", - "webp" + "webp", ] fp = ft.FilePicker() - page.services.append(fp) msg = ft.Text() + def on_message(e: flet_charts.MatplotlibChartMessageEvent): msg.value = e.message @@ -140,22 +145,46 @@ async def download_click(): fmt = dwnld_fmt.value buffer = mpl.download(fmt) title = fig.canvas.manager.get_window_title() - await fp.save_file_async(file_name=f"{title}.{fmt}", src_bytes=buffer) + await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) - mpl = flet_charts.MatplotlibChart(figure=fig, expand=True, on_message=on_message, on_toolbar_buttons_update=on_toolbar_update) + mpl = flet_charts.MatplotlibChart( + figure=fig, + expand=True, + on_message=on_message, + on_toolbar_buttons_update=on_toolbar_update, + ) # fig1.canvas.start() page.add( - ft.Row([ - ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), - back_btn := ft.IconButton(ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back()), - fwd_btn := ft.IconButton(ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward()), - pan_btn := ft.IconButton(ft.Icons.OPEN_WITH, selected_icon=ft.Icons.OPEN_WITH, selected_icon_color=ft.Colors.AMBER_800, on_click=pan_click), - zoom_btn := ft.IconButton(ft.Icons.SEARCH, selected_icon=ft.Icons.SEARCH, selected_icon_color=ft.Colors.AMBER_800, on_click=zoom_click), - ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - dwnld_fmt := ft.Dropdown(value="png", options=[ft.DropdownOption(fmt) for fmt in download_formats]), - msg - ]), + ft.Row( + [ + ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), + back_btn := ft.IconButton( + ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back() + ), + fwd_btn := ft.IconButton( + ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward() + ), + pan_btn := ft.IconButton( + ft.Icons.OPEN_WITH, + selected_icon=ft.Icons.OPEN_WITH, + selected_icon_color=ft.Colors.AMBER_800, + on_click=pan_click, + ), + zoom_btn := ft.IconButton( + ft.Icons.SEARCH, + selected_icon=ft.Icons.SEARCH, + selected_icon_color=ft.Colors.AMBER_800, + on_click=zoom_click, + ), + ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), + dwnld_fmt := ft.Dropdown( + value="png", + options=[ft.DropdownOption(fmt) for fmt in download_formats], + ), + msg, + ] + ), mpl, ) From b749ee625f9347252a14ba07cfd01b1c3f4b78f5 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 1 Oct 2025 12:58:09 -0700 Subject: [PATCH 12/16] Add MatplotlibChartWithToolbar and update test Introduces MatplotlibChartWithToolbar for integrated chart and toolbar controls. Updates __init__.py to export the new class, refactors tests/mpl_v2_basic.py to use MatplotlibChartWithToolbar, and adds debug output and minor improvements to MatplotlibChart. --- src/flet_charts/__init__.py | 2 + src/flet_charts/matplotlib_chart.py | 5 +- .../matplotlib_chart_with_toolbar.py | 110 +++++++++++++ tests/mpl_v2_basic.py | 149 ++++-------------- 4 files changed, 151 insertions(+), 115 deletions(-) create mode 100644 src/flet_charts/matplotlib_chart_with_toolbar.py diff --git a/src/flet_charts/__init__.py b/src/flet_charts/__init__.py index b35b20e..c9b0e56 100644 --- a/src/flet_charts/__init__.py +++ b/src/flet_charts/__init__.py @@ -24,6 +24,7 @@ MatplotlibChartMessageEvent, MatplotlibChartToolbarButtonsUpdateEvent, ) +from flet_charts.matplotlib_chart_with_toolbar import MatplotlibChartWithToolbar from flet_charts.pie_chart import PieChart, PieChartEvent from flet_charts.pie_chart_section import PieChartSection from flet_charts.plotly_chart import PlotlyChart @@ -84,4 +85,5 @@ "ScatterChartTooltip", "MatplotlibChartMessageEvent", "MatplotlibChartToolbarButtonsUpdateEvent", + "MatplotlibChartWithToolbar", ] diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 66afbc7..70a8057 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -355,6 +355,7 @@ def send_message(self, message): manager.handle_json(message) def send_json(self, content): + print(f"send_json: {content}") self._main_loop.call_soon_threadsafe( lambda: self._receive_queue.put_nowait((False, content)) ) @@ -378,4 +379,6 @@ async def on_canvas_resize(self, e: fc.CanvasResizeEvent): self.send_message({"type": "refresh"}) self._width = e.width self._height = e.height - self.send_message({"type": "resize", "width": e.width, "height": e.height}) + self.send_message( + {"type": "resize", "width": self._width, "height": self._height} + ) diff --git a/src/flet_charts/matplotlib_chart_with_toolbar.py b/src/flet_charts/matplotlib_chart_with_toolbar.py new file mode 100644 index 0000000..a7d5a10 --- /dev/null +++ b/src/flet_charts/matplotlib_chart_with_toolbar.py @@ -0,0 +1,110 @@ +from dataclasses import field + +import flet as ft +from matplotlib.figure import Figure + +import flet_charts + +_download_formats = [ + "eps", + "jpeg", + "pgf", + "pdf", + "png", + "ps", + "raw", + "svg", + "tif", + "webp", +] + + +@ft.control(kw_only=True) +class MatplotlibChartWithToolbar(ft.Column): + figure: Figure = field(metadata={"skip": True}) + """ + Matplotlib figure to draw - an instance of + [`matplotlib.figure.Figure`](https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure). + """ + + def build(self): + self.mpl = flet_charts.MatplotlibChart( + figure=self.figure, + expand=True, + on_message=self.on_message, + on_toolbar_buttons_update=self.on_toolbar_update, + ) + self.home_btn = ft.IconButton(ft.Icons.HOME, on_click=lambda: self.mpl.home()) + self.back_btn = ft.IconButton( + ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: self.mpl.back() + ) + self.fwd_btn = ft.IconButton( + ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: self.mpl.forward() + ) + self.pan_btn = ft.IconButton( + ft.Icons.OPEN_WITH, + selected_icon=ft.Icons.OPEN_WITH, + selected_icon_color=ft.Colors.AMBER_800, + on_click=self.pan_click, + ) + self.zoom_btn = ft.IconButton( + ft.Icons.ZOOM_IN, + selected_icon=ft.Icons.ZOOM_IN, + selected_icon_color=ft.Colors.AMBER_800, + on_click=self.zoom_click, + ) + self.download_btn = ft.IconButton( + ft.Icons.DOWNLOAD, on_click=self.download_click + ) + self.download_fmt = ft.Dropdown( + value="png", + options=[ft.DropdownOption(fmt) for fmt in _download_formats], + ) + self.msg = ft.Text() + self.controls = [ + ft.Row( + [ + self.home_btn, + self.back_btn, + self.fwd_btn, + self.pan_btn, + self.zoom_btn, + self.download_btn, + self.download_fmt, + self.msg, + ] + ), + self.mpl, + ] + if not self.expand: + if not self.height: + self.height = self.figure.bbox.height + if not self.width: + self.width = self.figure.bbox.width + + def on_message(self, e: flet_charts.MatplotlibChartMessageEvent): + print(f"on_message: {e.message}") + self.msg.value = e.message + + def on_toolbar_update( + self, e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent + ): + self.back_btn.disabled = not e.back_enabled + self.fwd_btn.disabled = not e.forward_enabled + + def pan_click(self): + self.mpl.pan() + self.pan_btn.selected = not self.pan_btn.selected + self.zoom_btn.selected = False + + def zoom_click(self): + self.mpl.zoom() + self.pan_btn.selected = False + self.zoom_btn.selected = not self.zoom_btn.selected + + async def download_click(self): + fmt = self.download_fmt.value + buffer = self.mpl.download(fmt) + title = self.figure.canvas.manager.get_window_title() + fp = ft.FilePicker() + await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 72c4ed4..207f20e 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -13,123 +13,44 @@ def main(page: ft.Page): - # Sample data - x = np.linspace(0, 10, 100) - y = np.sin(x) + # # Sample data + # x = np.linspace(0, 10, 100) + # y = np.sin(x) + + # # Plot + # fig = plt.figure() + # print("Figure number:", fig.number) + # plt.plot(x, y) + # plt.title("Interactive Sine Wave") + # plt.xlabel("X axis") + # plt.ylabel("Y axis") + # plt.grid(True) + + # ---------------------------------------------------------- + + plt.style.use("_mpl-gallery") + + # Make data for a double helix + n = 50 + theta = np.linspace(0, 2 * np.pi, n) + x1 = np.cos(theta) + y1 = np.sin(theta) + z1 = np.linspace(0, 1, n) + x2 = np.cos(theta + np.pi) + y2 = np.sin(theta + np.pi) + z2 = z1 # Plot - fig = plt.figure() - print("Figure number:", fig.number) - plt.plot(x, y) - plt.title("Interactive Sine Wave") - plt.xlabel("X axis") - plt.ylabel("Y axis") - plt.grid(True) - - # plt.style.use('_mpl-gallery') - - # # Make data for a double helix - # n = 50 - # theta = np.linspace(0, 2*np.pi, n) - # x1 = np.cos(theta) - # y1 = np.sin(theta) - # z1 = np.linspace(0, 1, n) - # x2 = np.cos(theta + np.pi) - # y2 = np.sin(theta + np.pi) - # z2 = z1 + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) + ax.plot(x1, y1, z1, linewidth=2, color="C0") + ax.plot(x2, y2, z2, linewidth=2, color="C0") - # # Plot - # fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) - # ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) - # ax.plot(x1, y1, z1, linewidth=2, color='C0') - # ax.plot(x2, y2, z2, linewidth=2, color='C0') - - # ax.set(xticklabels=[], - # yticklabels=[], - # zticklabels=[]) - - # plt.show() - - download_formats = [ - "eps", - "jpeg", - "pgf", - "pdf", - "png", - "ps", - "raw", - "svg", - "tif", - "webp", - ] - - fp = ft.FilePicker() - - msg = ft.Text() - - def on_message(e: flet_charts.MatplotlibChartMessageEvent): - msg.value = e.message - - def on_toolbar_update(e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent): - back_btn.disabled = not e.back_enabled - fwd_btn.disabled = not e.forward_enabled - - def pan_click(): - mpl.pan() - pan_btn.selected = not pan_btn.selected - zoom_btn.selected = False - - def zoom_click(): - mpl.zoom() - pan_btn.selected = False - zoom_btn.selected = not zoom_btn.selected - - async def download_click(): - fmt = dwnld_fmt.value - buffer = mpl.download(fmt) - title = fig.canvas.manager.get_window_title() - await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) - - mpl = flet_charts.MatplotlibChart( - figure=fig, - expand=True, - on_message=on_message, - on_toolbar_buttons_update=on_toolbar_update, - ) - - # fig1.canvas.start() - page.add( - ft.Row( - [ - ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), - back_btn := ft.IconButton( - ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back() - ), - fwd_btn := ft.IconButton( - ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward() - ), - pan_btn := ft.IconButton( - ft.Icons.PAN_TOOL_OUTLINED, - selected_icon=ft.Icons.PAN_TOOL_OUTLINED, - selected_icon_color=ft.Colors.AMBER_800, - on_click=pan_click, - ), - zoom_btn := ft.IconButton( - ft.Icons.ZOOM_IN, - selected_icon=ft.Icons.ZOOM_IN, - selected_icon_color=ft.Colors.AMBER_800, - on_click=zoom_click, - ), - ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - dwnld_fmt := ft.Dropdown( - value="png", - options=[ft.DropdownOption(fmt) for fmt in download_formats], - ), - msg, - ] - ), - mpl, - ) + ax.set(xticklabels=[], yticklabels=[], zticklabels=[]) + + plt.show() + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) ft.run(main) From 395b42b77b17b395a69191e1cea9778af9518563 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 1 Oct 2025 18:06:03 -0700 Subject: [PATCH 13/16] Refactor chart components and update tests Added 'isolated=True' to chart controls for better encapsulation. Replaced print statements with logger.debug for improved logging. Refactored test files to use MatplotlibChartWithToolbar, simplifying toolbar and event handling logic, and added a new 3D chart test. --- src/flet_charts/matplotlib_chart.py | 9 +- .../matplotlib_chart_with_toolbar.py | 5 +- tests/mpl_v2_3d.py | 39 ++++++++ tests/mpl_v2_animate.py | 83 +---------------- tests/mpl_v2_basic.py | 44 ++------- tests/mpl_v2_events.py | 92 +------------------ 6 files changed, 59 insertions(+), 213 deletions(-) create mode 100644 tests/mpl_v2_3d.py diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index 70a8057..f709253 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -53,7 +53,7 @@ class MatplotlibChartToolbarButtonsUpdateEvent(ft.Event["MatplotlibChart"]): """ -@ft.control(kw_only=True) +@ft.control(kw_only=True, isolated=True) class MatplotlibChart(ft.GestureDetector): """ Displays a [Matplotlib](https://matplotlib.org/) chart. @@ -84,8 +84,6 @@ class MatplotlibChart(ft.GestureDetector): """ def build(self): - # self.on_resize = self.on_canvas_resize - # self.shapes = [fc.Line(x1=0, y1=0, x2=50, y2=50)] self.mouse_cursor = ft.MouseCursor.WAIT self.__started = False self.__dpr = self.page.media.device_pixel_ratio @@ -120,9 +118,6 @@ def build(self): self._height = 0 self._waiting = False - # def before_update(self): - # super().before_update() - def _on_key_down(self, e): logger.debug(f"ON KEY DOWN: {e}") @@ -355,7 +350,7 @@ def send_message(self, message): manager.handle_json(message) def send_json(self, content): - print(f"send_json: {content}") + logger.debug(f"send_json: {content}") self._main_loop.call_soon_threadsafe( lambda: self._receive_queue.put_nowait((False, content)) ) diff --git a/src/flet_charts/matplotlib_chart_with_toolbar.py b/src/flet_charts/matplotlib_chart_with_toolbar.py index a7d5a10..2f32834 100644 --- a/src/flet_charts/matplotlib_chart_with_toolbar.py +++ b/src/flet_charts/matplotlib_chart_with_toolbar.py @@ -19,7 +19,7 @@ ] -@ft.control(kw_only=True) +@ft.control(kw_only=True, isolated=True) class MatplotlibChartWithToolbar(ft.Column): figure: Figure = field(metadata={"skip": True}) """ @@ -83,14 +83,15 @@ def build(self): self.width = self.figure.bbox.width def on_message(self, e: flet_charts.MatplotlibChartMessageEvent): - print(f"on_message: {e.message}") self.msg.value = e.message + self.msg.update() def on_toolbar_update( self, e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent ): self.back_btn.disabled = not e.back_enabled self.fwd_btn.disabled = not e.forward_enabled + self.update() def pan_click(self): self.mpl.pan() diff --git a/tests/mpl_v2_3d.py b/tests/mpl_v2_3d.py new file mode 100644 index 0000000..cdc0894 --- /dev/null +++ b/tests/mpl_v2_3d.py @@ -0,0 +1,39 @@ +import logging + +import flet as ft +import matplotlib +import matplotlib.pyplot as plt +import numpy as np + +import flet_charts + +matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") + +logging.basicConfig(level=logging.INFO) + + +def main(page: ft.Page): + plt.style.use("_mpl-gallery") + + # Make data for a double helix + n = 50 + theta = np.linspace(0, 2 * np.pi, n) + x1 = np.cos(theta) + y1 = np.sin(theta) + z1 = np.linspace(0, 1, n) + x2 = np.cos(theta + np.pi) + y2 = np.sin(theta + np.pi) + z2 = z1 + + # Plot + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) + ax.plot(x1, y1, z1, linewidth=2, color="C0") + ax.plot(x2, y2, z2, linewidth=2, color="C0") + + ax.set(xticklabels=[], yticklabels=[], zticklabels=[]) + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) + + +ft.run(main) diff --git a/tests/mpl_v2_animate.py b/tests/mpl_v2_animate.py index d2b2f94..c88c5d7 100644 --- a/tests/mpl_v2_animate.py +++ b/tests/mpl_v2_animate.py @@ -53,88 +53,7 @@ def update_lines(num, walks, lines): fig, update_lines, num_steps, fargs=(walks, lines), interval=100 ) - plt.show() - - download_formats = [ - "eps", - "jpeg", - "pgf", - "pdf", - "png", - "ps", - "raw", - "svg", - "tif", - "webp", - ] - - fp = ft.FilePicker() - - msg = ft.Text() - - def on_message(e: flet_charts.MatplotlibChartMessageEvent): - msg.value = e.message - - def on_toolbar_update(e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent): - back_btn.disabled = not e.back_enabled - fwd_btn.disabled = not e.forward_enabled - - def pan_click(): - mpl.pan() - pan_btn.selected = not pan_btn.selected - zoom_btn.selected = False - - def zoom_click(): - mpl.zoom() - pan_btn.selected = False - zoom_btn.selected = not zoom_btn.selected - - async def download_click(): - fmt = dwnld_fmt.value - buffer = mpl.download(fmt) - title = fig.canvas.manager.get_window_title() - await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) - - mpl = flet_charts.MatplotlibChart( - figure=fig, - expand=True, - on_message=on_message, - on_toolbar_buttons_update=on_toolbar_update, - ) - - # fig1.canvas.start() - page.add( - ft.Row( - [ - ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), - back_btn := ft.IconButton( - ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back() - ), - fwd_btn := ft.IconButton( - ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward() - ), - pan_btn := ft.IconButton( - ft.Icons.PAN_TOOL_OUTLINED, - selected_icon=ft.Icons.PAN_TOOL_OUTLINED, - selected_icon_color=ft.Colors.AMBER_800, - on_click=pan_click, - ), - zoom_btn := ft.IconButton( - ft.Icons.ZOOM_IN, - selected_icon=ft.Icons.ZOOM_IN, - selected_icon_color=ft.Colors.AMBER_800, - on_click=zoom_click, - ), - ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - dwnld_fmt := ft.Dropdown( - value="png", - options=[ft.DropdownOption(fmt) for fmt in download_formats], - ), - msg, - ] - ), - mpl, - ) + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) ft.run(main) diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index 207f20e..b4ea7bf 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -13,42 +13,18 @@ def main(page: ft.Page): - # # Sample data - # x = np.linspace(0, 10, 100) - # y = np.sin(x) - - # # Plot - # fig = plt.figure() - # print("Figure number:", fig.number) - # plt.plot(x, y) - # plt.title("Interactive Sine Wave") - # plt.xlabel("X axis") - # plt.ylabel("Y axis") - # plt.grid(True) - - # ---------------------------------------------------------- - - plt.style.use("_mpl-gallery") - - # Make data for a double helix - n = 50 - theta = np.linspace(0, 2 * np.pi, n) - x1 = np.cos(theta) - y1 = np.sin(theta) - z1 = np.linspace(0, 1, n) - x2 = np.cos(theta + np.pi) - y2 = np.sin(theta + np.pi) - z2 = z1 + # Sample data + x = np.linspace(0, 10, 100) + y = np.sin(x) # Plot - fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) - ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) - ax.plot(x1, y1, z1, linewidth=2, color="C0") - ax.plot(x2, y2, z2, linewidth=2, color="C0") - - ax.set(xticklabels=[], yticklabels=[], zticklabels=[]) - - plt.show() + fig = plt.figure() + print("Figure number:", fig.number) + plt.plot(x, y) + plt.title("Interactive Sine Wave") + plt.xlabel("X axis") + plt.ylabel("Y axis") + plt.grid(True) page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) diff --git a/tests/mpl_v2_events.py b/tests/mpl_v2_events.py index c09ff83..e070113 100644 --- a/tests/mpl_v2_events.py +++ b/tests/mpl_v2_events.py @@ -46,18 +46,13 @@ def on_press(self, event): return if event.key not in ("n", "p"): return - if event.key == "n": - inc = 1 - else: - inc = -1 + inc = 1 if event.key == "n" else -1 self.lastind += inc self.lastind = np.clip(self.lastind, 0, len(xs) - 1) self.update() def on_pick(self, event): - print("ON PICK") - if event.artist != line: return True @@ -105,88 +100,9 @@ def update(self): fig.canvas.mpl_connect("pick_event", browser.on_pick) fig.canvas.mpl_connect("key_press_event", browser.on_press) - plt.show() - - download_formats = [ - "eps", - "jpeg", - "pgf", - "pdf", - "png", - "ps", - "raw", - "svg", - "tif", - "webp", - ] - - fp = ft.FilePicker() - - msg = ft.Text() - - def on_message(e: flet_charts.MatplotlibChartMessageEvent): - msg.value = e.message - - def on_toolbar_update(e: flet_charts.MatplotlibChartToolbarButtonsUpdateEvent): - back_btn.disabled = not e.back_enabled - fwd_btn.disabled = not e.forward_enabled - - def pan_click(): - mpl.pan() - pan_btn.selected = not pan_btn.selected - zoom_btn.selected = False - - def zoom_click(): - mpl.zoom() - pan_btn.selected = False - zoom_btn.selected = not zoom_btn.selected - - async def download_click(): - fmt = dwnld_fmt.value - buffer = mpl.download(fmt) - title = fig.canvas.manager.get_window_title() - await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) - - mpl = flet_charts.MatplotlibChart( - figure=fig, - expand=True, - on_message=on_message, - on_toolbar_buttons_update=on_toolbar_update, - ) - - # fig1.canvas.start() - page.add( - ft.Row( - [ - ft.IconButton(ft.Icons.HOME, on_click=lambda: mpl.home()), - back_btn := ft.IconButton( - ft.Icons.ARROW_BACK_ROUNDED, on_click=lambda: mpl.back() - ), - fwd_btn := ft.IconButton( - ft.Icons.ARROW_FORWARD_ROUNDED, on_click=lambda: mpl.forward() - ), - pan_btn := ft.IconButton( - ft.Icons.OPEN_WITH, - selected_icon=ft.Icons.OPEN_WITH, - selected_icon_color=ft.Colors.AMBER_800, - on_click=pan_click, - ), - zoom_btn := ft.IconButton( - ft.Icons.SEARCH, - selected_icon=ft.Icons.SEARCH, - selected_icon_color=ft.Colors.AMBER_800, - on_click=zoom_click, - ), - ft.IconButton(ft.Icons.DOWNLOAD, on_click=download_click), - dwnld_fmt := ft.Dropdown( - value="png", - options=[ft.DropdownOption(fmt) for fmt in download_formats], - ), - msg, - ] - ), - mpl, - ) + # plt.show() + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) ft.run(main) From c5179d7368a86aa54226fe05b25b07631a086206 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 1 Oct 2025 18:20:57 -0700 Subject: [PATCH 14/16] Set matplotlib backend in library, remove from tests Moved the matplotlib backend configuration to the main library file (matplotlib_chart.py) and removed redundant backend setting from all test files. This centralizes backend setup and simplifies test scripts. --- src/flet_charts/matplotlib_chart.py | 3 +++ tests/mpl_v2_3d.py | 3 --- tests/mpl_v2_animate.py | 3 --- tests/mpl_v2_basic.py | 3 --- tests/mpl_v2_events.py | 5 ----- 5 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/flet_charts/matplotlib_chart.py b/src/flet_charts/matplotlib_chart.py index f709253..bf917c2 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -8,6 +8,7 @@ import flet.canvas as fc try: + import matplotlib from matplotlib.figure import Figure except ImportError as e: raise Exception( @@ -22,6 +23,8 @@ logger = logging.getLogger("flet-charts.matplotlib") +matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") + figure_cursors = { "default": None, "pointer": ft.MouseCursor.CLICK, diff --git a/tests/mpl_v2_3d.py b/tests/mpl_v2_3d.py index cdc0894..8dbcc10 100644 --- a/tests/mpl_v2_3d.py +++ b/tests/mpl_v2_3d.py @@ -1,14 +1,11 @@ import logging import flet as ft -import matplotlib import matplotlib.pyplot as plt import numpy as np import flet_charts -matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") - logging.basicConfig(level=logging.INFO) diff --git a/tests/mpl_v2_animate.py b/tests/mpl_v2_animate.py index c88c5d7..d65f123 100644 --- a/tests/mpl_v2_animate.py +++ b/tests/mpl_v2_animate.py @@ -1,14 +1,11 @@ import logging import flet as ft -import matplotlib import matplotlib.pyplot as plt import numpy as np import flet_charts -matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") - logging.basicConfig(level=logging.INFO) state = {} diff --git a/tests/mpl_v2_basic.py b/tests/mpl_v2_basic.py index b4ea7bf..8c5c2cd 100644 --- a/tests/mpl_v2_basic.py +++ b/tests/mpl_v2_basic.py @@ -1,14 +1,11 @@ import logging import flet as ft -import matplotlib import matplotlib.pyplot as plt import numpy as np import flet_charts -matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") - logging.basicConfig(level=logging.INFO) diff --git a/tests/mpl_v2_events.py b/tests/mpl_v2_events.py index e070113..6f24a16 100644 --- a/tests/mpl_v2_events.py +++ b/tests/mpl_v2_events.py @@ -1,14 +1,9 @@ import flet as ft -import matplotlib import matplotlib.pyplot as plt import numpy as np import flet_charts -matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") - -# logging.basicConfig(level=logging.DEBUG) - state = {} From c4d7aa301129f9e1a5955abe235feaf7b0b31ca3 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 1 Oct 2025 18:23:56 -0700 Subject: [PATCH 15/16] Set figure size and remove expand in 3D plot test Specifies the figure size in the 3D plot test and removes the 'expand=True' argument from MatplotlibChartWithToolbar for consistency and improved layout control. --- tests/mpl_v2_3d.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/mpl_v2_3d.py b/tests/mpl_v2_3d.py index 8dbcc10..0b87396 100644 --- a/tests/mpl_v2_3d.py +++ b/tests/mpl_v2_3d.py @@ -22,15 +22,15 @@ def main(page: ft.Page): y2 = np.sin(theta + np.pi) z2 = z1 - # Plot - fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + # Plot with defined figure size + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}, figsize=(8, 6)) ax.fill_between(x1, y1, z1, x2, y2, z2, alpha=0.5) ax.plot(x1, y1, z1, linewidth=2, color="C0") ax.plot(x2, y2, z2, linewidth=2, color="C0") ax.set(xticklabels=[], yticklabels=[], zticklabels=[]) - page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig)) ft.run(main) From 122c6bd5338b106f2adc21c5f143bd2c914da79e Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 1 Oct 2025 18:25:34 -0700 Subject: [PATCH 16/16] Inline FilePicker instance in download method Refactored the download method to instantiate FilePicker inline instead of assigning it to a variable, simplifying the code. --- src/flet_charts/matplotlib_chart_with_toolbar.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/flet_charts/matplotlib_chart_with_toolbar.py b/src/flet_charts/matplotlib_chart_with_toolbar.py index 2f32834..c12b026 100644 --- a/src/flet_charts/matplotlib_chart_with_toolbar.py +++ b/src/flet_charts/matplotlib_chart_with_toolbar.py @@ -107,5 +107,4 @@ async def download_click(self): fmt = self.download_fmt.value buffer = self.mpl.download(fmt) title = self.figure.canvas.manager.get_window_title() - fp = ft.FilePicker() - await fp.save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) + await ft.FilePicker().save_file(file_name=f"{title}.{fmt}", src_bytes=buffer)