diff --git a/pyproject.toml b/pyproject.toml index eb36e40..5fb7bd0 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/__init__.py b/src/flet_charts/__init__.py index 1bdde40..c9b0e56 100644 --- a/src/flet_charts/__init__.py +++ b/src/flet_charts/__init__.py @@ -19,7 +19,12 @@ LineChartDataPoint, LineChartDataPointTooltip, ) -from flet_charts.matplotlib_chart import MatplotlibChart +from flet_charts.matplotlib_chart import ( + MatplotlibChart, + 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 @@ -78,4 +83,7 @@ "ScatterChartSpot", "ScatterChartSpotTooltip", "ScatterChartTooltip", + "MatplotlibChartMessageEvent", + "MatplotlibChartToolbarButtonsUpdateEvent", + "MatplotlibChartWithToolbar", ] 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 9af9696..bf917c2 100644 --- a/src/flet_charts/matplotlib_chart.py +++ b/src/flet_charts/matplotlib_chart.py @@ -1,22 +1,63 @@ -import io -import re -import xml.etree.ElementTree as ET -from dataclasses import field +import asyncio +import logging +from dataclasses import dataclass, field +from io import BytesIO +from typing import Optional import flet as ft +import flet.canvas as fc try: + import matplotlib from matplotlib.figure import Figure except ImportError as e: raise Exception( 'Install "matplotlib" Python package to use MatplotlibChart control.' ) from e -__all__ = ["MatplotlibChart"] +__all__ = [ + "MatplotlibChart", + "MatplotlibChartMessageEvent", + "MatplotlibChartToolbarButtonsUpdateEvent", +] +logger = logging.getLogger("flet-charts.matplotlib") -@ft.control(kw_only=True) -class MatplotlibChart(ft.Container): +matplotlib.use("module://flet_charts.matplotlib_backends.backend_flet_agg") + +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, +} + + +@dataclass +class MatplotlibChartMessageEvent(ft.Event["MatplotlibChart"]): + message: str + """ + 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, isolated=True) +class MatplotlibChart(ft.GestureDetector): """ Displays a [Matplotlib](https://matplotlib.org/) chart. @@ -33,33 +74,309 @@ 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 + on_message: Optional[ft.EventHandler[MatplotlibChartMessageEvent]] = None """ - Whether to display chart in original size. - - Set to `False` to display a chart that fits configured bounds. + The event is triggered on figure message update. """ - transparent: bool = False + on_toolbar_buttons_update: Optional[ + ft.EventHandler[MatplotlibChartToolbarButtonsUpdateEvent] + ] = None """ - Whether to remove the background from the chart. + Triggers when toolbar buttons status is updated. """ - 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 build(self): + self.mouse_cursor = ft.MouseCursor.WAIT + self.__started = False + self.__dpr = self.page.media.device_pixel_ratio + logger.debug(f"DPR: {self.__dpr}") + self.__image_mode = "full" + + self.canvas = fc.Canvas( + # resize_interval=10, + 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.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() + self._width = 0 + self._height = 0 + self._waiting = False + + def _on_key_down(self, e): + logger.debug(f"ON KEY DOWN: {e}") + + 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_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "figure_enter", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def _on_hover(self, e: ft.HoverEvent): + logger.debug(f"_on_hover: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "motion_notify", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def _on_exit(self, e: ft.HoverEvent): + logger.debug(f"_on_exit: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "figure_leave", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def _pan_start(self, e: ft.DragStartEvent): + logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}") + asyncio.create_task(self.keyboard_listener.focus()) + self.send_message( + { + "type": "button_press", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 1, + "modifiers": [], + } + ) + + def _pan_update(self, e: ft.DragUpdateEvent): + logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "motion_notify", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 1, + "modifiers": [], + } + ) + + def _pan_end(self, e: ft.DragEndEvent): + logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "button_release", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 0, + "modifiers": [], + } + ) + + def _right_pan_start(self, e: ft.PointerEvent): + logger.debug(f"_pan_start: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "button_press", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 2, + "buttons": 2, + "modifiers": [], + } + ) + + def _right_pan_update(self, e: ft.PointerEvent): + logger.debug(f"_pan_update: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "motion_notify", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 0, + "buttons": 2, + "modifiers": [], + } + ) + + def _right_pan_end(self, e: ft.PointerEvent): + logger.debug(f"_pan_end: {e.local_position.x}, {e.local_position.y}") + self.send_message( + { + "type": "button_release", + "x": e.local_position.x * self.__dpr, + "y": e.local_position.y * self.__dpr, + "button": 2, + "buttons": 0, + "modifiers": [], + } + ) + + def will_unmount(self): + self.figure.canvas.manager.remove_web_socket(self) + + def home(self): + logger.debug("home)") + self.send_message({"type": "toolbar_button", "name": "home"}) + + def back(self): + logger.debug("back()") + self.send_message({"type": "toolbar_button", "name": "back"}) + + def forward(self): + logger.debug("forward)") + self.send_message({"type": "toolbar_button", "name": "forward"}) + + def pan(self): + logger.debug("pan()") + self.send_message({"type": "toolbar_button", "name": "pan"}) + + def zoom(self): + logger.debug("zoom()") + self.send_message({"type": "toolbar_button", "name": "zoom"}) + + def download(self, format): + logger.debug(f"Download in format: {format}") + buff = BytesIO() + 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: + logger.debug(f"receive_binary({len(content)})") + if self.__image_mode == "full": + await self.canvas.clear_capture() + + 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, + ) + ] + ft.context.disable_auto_update() + self.canvas.update() + await self.canvas.capture() + self.img_count += 1 + self._waiting = False + else: + logger.debug(f"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 + self.send_message({"type": "draw"}) + 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 + ): + 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.update() + elif content["type"] == "resize": + 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"], + }, + ) + + def send_message(self, message): + logger.debug(f"send_message({message})") + manager = self.figure.canvas.manager + if manager is not None: + manager.handle_json(message) + + def send_json(self, content): + logger.debug(f"send_json: {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): + logger.debug(f"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": 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..c12b026 --- /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, isolated=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): + 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() + 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() + await ft.FilePicker().save_file(file_name=f"{title}.{fmt}", src_bytes=buffer) diff --git a/tests/mpl_v2_3d.py b/tests/mpl_v2_3d.py new file mode 100644 index 0000000..0b87396 --- /dev/null +++ b/tests/mpl_v2_3d.py @@ -0,0 +1,36 @@ +import logging + +import flet as ft +import matplotlib.pyplot as plt +import numpy as np + +import flet_charts + +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 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)) + + +ft.run(main) diff --git a/tests/mpl_v2_animate.py b/tests/mpl_v2_animate.py new file mode 100644 index 0000000..d65f123 --- /dev/null +++ b/tests/mpl_v2_animate.py @@ -0,0 +1,56 @@ +import logging + +import flet as ft +import matplotlib.pyplot as plt +import numpy as np + +import flet_charts + +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 + ) + + 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 new file mode 100644 index 0000000..8c5c2cd --- /dev/null +++ b/tests/mpl_v2_basic.py @@ -0,0 +1,29 @@ +import logging + +import flet as ft +import matplotlib.pyplot as plt +import numpy as np + +import flet_charts + +logging.basicConfig(level=logging.INFO) + + +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) + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) + + +ft.run(main) diff --git a/tests/mpl_v2_events.py b/tests/mpl_v2_events.py new file mode 100644 index 0000000..6f24a16 --- /dev/null +++ b/tests/mpl_v2_events.py @@ -0,0 +1,103 @@ +import flet as ft +import matplotlib.pyplot as plt +import numpy as np + +import flet_charts + +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 + 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): + 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() + + page.add(flet_charts.MatplotlibChartWithToolbar(figure=fig, expand=True)) + + +ft.run(main) 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 }