bindmc 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {bindmc-0.1.2 → bindmc-0.1.3}/PKG-INFO +5 -5
  2. {bindmc-0.1.2 → bindmc-0.1.3}/README.md +3 -3
  3. {bindmc-0.1.2 → bindmc-0.1.3}/pyproject.toml +2 -2
  4. bindmc-0.1.3/src/bindmc/__main__.py +4 -0
  5. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/main.py +44 -3
  6. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/bayes.py +14 -13
  7. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/data_gen.py +5 -1
  8. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/fitting.py +5 -5
  9. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/graph.py +38 -6
  10. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/simulation.py +6 -6
  11. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/state/statemanager.py +2 -2
  12. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/utils.py +52 -0
  13. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/__init__.py +0 -0
  14. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/Class model.md +0 -0
  15. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/TODO.md +0 -0
  16. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/TODO_old.md +0 -0
  17. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/__init__.py +0 -0
  18. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/app.py +0 -0
  19. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/BindingConstant.py +0 -0
  20. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/ChemicalShiftParam.py +0 -0
  21. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/Component.py +0 -0
  22. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/ExptData.py +0 -0
  23. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/ExptDataType.py +0 -0
  24. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/FitResult.py +0 -0
  25. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/MCMCSim.py +0 -0
  26. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/Model.py +0 -0
  27. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/RawData.py +0 -0
  28. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/Simulation.py +0 -0
  29. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/UIBindings.py +0 -0
  30. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/classes/__init__.py +0 -0
  31. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/__init__.py +0 -0
  32. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/base.py +0 -0
  33. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/bayes_priors.py +0 -0
  34. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/binding_model.py +0 -0
  35. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/body.py +0 -0
  36. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/data_import.py +0 -0
  37. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/data_model.py +0 -0
  38. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/components/header.py +0 -0
  39. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/default_models.json +0 -0
  40. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/export/__init__.py +0 -0
  41. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/export/notebook_exporter.py +0 -0
  42. {bindmc-0.1.2 → bindmc-0.1.3}/src/bindmc/webgui/state/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bindmc
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Keywords: chemistry,analytical chemistry,binding constants,supramolecular
5
5
  Author: Martin Peeks
6
6
  Author-email: Martin Peeks <martinp23@googlemail.com>, m.peeks@unsw.edu.au
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.14
12
12
  Classifier: Topic :: Scientific/Engineering :: Chemistry
13
13
  Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
14
  Requires-Dist: arviz==0.21.0
15
- Requires-Dist: bindtools>=0.1.0
15
+ Requires-Dist: bindtools>=0.1.2
16
16
  Requires-Dist: corner==2.2.3
17
17
  Requires-Dist: emcee==3.1.6
18
18
  Requires-Dist: h5py==3.13.0
@@ -38,8 +38,8 @@ Description-Content-Type: text/markdown
38
38
 
39
39
  # bindmc
40
40
 
41
- [![PyPI version](https://badge.fury.io/py/bindtools.svg)](https://badge.fury.io/py/bindtools)
42
- [![Python Version](https://img.shields.io/pypi/pyversions/bindtools.svg)](https://pypi.org/project/bindtools/)
41
+ [![PyPI version](https://badge.fury.io/py/bindmc.svg)](https://badge.fury.io/py/bindmc)
42
+ [![Python Version](https://img.shields.io/pypi/pyversions/bindmc.svg)](https://pypi.org/project/bindmc/)
43
43
 
44
44
  `bindmc` is a tool for calculating binding constants, built on top of [bindtools](https://github.com/martinp23/bindtools).
45
45
 
@@ -60,4 +60,4 @@ If using the pre-built binary, run the downloaded executable.
60
60
  If installed via pip, run:
61
61
  ```bash
62
62
  python -m bindmc
63
- ```
63
+ ```
@@ -1,7 +1,7 @@
1
1
  # bindmc
2
2
 
3
- [![PyPI version](https://badge.fury.io/py/bindtools.svg)](https://badge.fury.io/py/bindtools)
4
- [![Python Version](https://img.shields.io/pypi/pyversions/bindtools.svg)](https://pypi.org/project/bindtools/)
3
+ [![PyPI version](https://badge.fury.io/py/bindmc.svg)](https://badge.fury.io/py/bindmc)
4
+ [![Python Version](https://img.shields.io/pypi/pyversions/bindmc.svg)](https://pypi.org/project/bindmc/)
5
5
 
6
6
  `bindmc` is a tool for calculating binding constants, built on top of [bindtools](https://github.com/martinp23/bindtools).
7
7
 
@@ -22,4 +22,4 @@ If using the pre-built binary, run the downloaded executable.
22
22
  If installed via pip, run:
23
23
  ```bash
24
24
  python -m bindmc
25
- ```
25
+ ```
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bindmc"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  readme = "README.md"
9
9
  keywords = ["chemistry", "analytical chemistry", "binding constants", "supramolecular"]
10
10
  classifiers = [
@@ -28,7 +28,7 @@ classifiers = [
28
28
  requires-python = ">=3.12"
29
29
  dependencies = [
30
30
  "arviz==0.21.0",
31
- "bindtools>=0.1.0",
31
+ "bindtools>=0.1.2",
32
32
  "corner==2.2.3",
33
33
  "emcee==3.1.6",
34
34
  "h5py==3.13.0",
@@ -0,0 +1,4 @@
1
+ """Entry point for python -m bindmc."""
2
+
3
+ if __name__ in {"__main__"}:
4
+ import bindmc.main
@@ -13,6 +13,7 @@ import matplotlib
13
13
 
14
14
  matplotlib.use("module://matplotlib.backends.backend_svg")
15
15
  import sys
16
+ import webview
16
17
  from nicegui import native, ui, app
17
18
  from bindmc.webgui.app import BindMCServer
18
19
  import logging
@@ -24,6 +25,32 @@ from importlib.metadata import version
24
25
 
25
26
  __version__ = version("bindmc")
26
27
 
28
+ def is_webview_available() -> bool:
29
+ """Check if pywebview GUI libraries can be initialized in-process."""
30
+ try:
31
+ from webview.guilib import initialize
32
+ from webview.util import WebViewException
33
+
34
+ # Temporarily suppress pywebview's internal logger to prevent
35
+ # missing backend errors from spamming your console output.
36
+ logger = logging.getLogger('pywebview')
37
+ old_level = logger.level
38
+ logger.setLevel(logging.CRITICAL)
39
+
40
+ try:
41
+ # Attempts to load the default system GUI engine (e.g. Edge, Cocoa, GTK, QT)
42
+ initialize()
43
+ return True
44
+ except (WebViewException, ImportError, Exception):
45
+ return False
46
+ finally:
47
+ logger.setLevel(old_level)
48
+
49
+ except ImportError:
50
+ # pywebview itself isn't even installed
51
+ return False
52
+
53
+
27
54
  logger = logging.getLogger(__name__)
28
55
 
29
56
  try:
@@ -43,6 +70,8 @@ else:
43
70
  raise RuntimeError(f"NiceGUI >= 3 is required; found {nicegui.__version__}")
44
71
 
45
72
 
73
+
74
+
46
75
  app.native.settings["ALLOW_DOWNLOADS"] = True
47
76
 
48
77
  # logging.basicConfig(level=logging.INFO, filename='BindMC.log')
@@ -51,17 +80,26 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
51
80
  logger.info(f"Starting BindMC {__version__} NiceGUI server...")
52
81
  BindMCServer()
53
82
 
54
- # Set DEV based on whether running from PyInstaller bundle
55
- DEV = not getattr(sys, "frozen", False)
83
+ # pyinstaller?
84
+ is_frozen = getattr(sys, "frozen", False)
85
+
86
+ is_module_run = bool(globals().get("__package__"))
87
+
88
+ DEV = not (is_frozen or is_module_run)
56
89
 
90
+ native_mode = False
57
91
  if DEV:
58
92
  native_mode = False
59
93
  reload = True
60
94
  else:
61
- native_mode = True
95
+ native_mode = is_webview_available()
96
+ if native_mode is False:
97
+ logger.warning("Native mode is not available; running via browser.")
62
98
  reload = False
63
99
 
64
100
 
101
+
102
+
65
103
  # make a sensible storage path for native mode
66
104
  storage_path = Path(user_data_dir(appname="BindMC", appauthor=False))
67
105
  storage_path.mkdir(parents=True, exist_ok=True)
@@ -69,4 +107,7 @@ storage_path.mkdir(parents=True, exist_ok=True)
69
107
  # Redirect native window persistence data away from default paths
70
108
  app.native.start_args["storage_path"] = str(storage_path)
71
109
 
110
+
72
111
  ui.run(title="BindMC", reload=reload, native=native_mode, port=native.find_open_port(), storage_secret="bindmc_secret")
112
+
113
+
@@ -6,7 +6,7 @@ import zipfile
6
6
  import numpy as np
7
7
  import pandas as pd
8
8
  from ..classes import MCMCSim
9
- from ..utils import safe_filename
9
+ from ..utils import safe_filename, custom_download
10
10
  from functools import partial
11
11
  import asyncio
12
12
  import re
@@ -568,13 +568,13 @@ class BayesPanel(BaseComponent):
568
568
  f.tight_layout()
569
569
  self.result_corner.update()
570
570
 
571
- def _download_figure(self, fig, filename: str) -> None:
571
+ async def _download_figure(self, fig, filename: str) -> None:
572
572
  buf = io.BytesIO()
573
573
  fig.savefig(buf, format="png", dpi=_EXPORT_DPI, bbox_inches="tight")
574
574
  buf.seek(0)
575
- ui.download.content(buf.getvalue(), filename=filename)
575
+ await custom_download(buf.getvalue(), filename=filename)
576
576
 
577
- def download_chain_figure(self) -> None:
577
+ async def download_chain_figure(self) -> None:
578
578
  if not hasattr(self, "mcmc") or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
579
579
  ui.notify("No chain figure available for download.", type="warning")
580
580
  return
@@ -588,10 +588,10 @@ class BayesPanel(BaseComponent):
588
588
  active_fit = self.sm.active_fit_or_none
589
589
  stem = active_fit.name if active_fit is not None else "mcmc"
590
590
  filename = f"{safe_filename(stem, fallback='mcmc')}_chains.png"
591
- self._download_figure(fig, filename)
591
+ await self._download_figure(fig, filename)
592
592
  plt.close(fig)
593
593
 
594
- def download_corner_figure(self) -> None:
594
+ async def download_corner_figure(self) -> None:
595
595
  if not hasattr(self, "mcmc") or self.mcmc.mc is None or self.mcmc.mc.sampler is None:
596
596
  ui.notify("No corner figure available for download.", type="warning")
597
597
  return
@@ -606,7 +606,7 @@ class BayesPanel(BaseComponent):
606
606
  active_fit = self.sm.active_fit_or_none
607
607
  stem = active_fit.name if active_fit is not None else "mcmc"
608
608
  filename = f"{safe_filename(stem, fallback='mcmc')}_corner.png"
609
- self._download_figure(fig, filename)
609
+ await self._download_figure(fig, filename)
610
610
  plt.close(fig)
611
611
 
612
612
  # ------------------------------------------------------------------
@@ -646,19 +646,20 @@ class BayesPanel(BaseComponent):
646
646
  "text-sm text-orange-600 mt-1"
647
647
  )
648
648
 
649
+ async def export_and_close():
650
+ dialog.close()
651
+ await self._do_export_notebook(include_chains["value"])
652
+
649
653
  with ui.row().classes("mt-4 gap-2 justify-end w-full"):
650
654
  ui.button("Cancel", on_click=dialog.close)
651
655
  ui.button(
652
656
  "Export",
653
- on_click=lambda: (
654
- dialog.close(),
655
- self._do_export_notebook(include_chains["value"]),
656
- ),
657
+ on_click=export_and_close,
657
658
  ).props("color=primary")
658
659
 
659
660
  dialog.open()
660
661
 
661
- def _do_export_notebook(self, include_chains: bool) -> None:
662
+ async def _do_export_notebook(self, include_chains: bool) -> None:
662
663
  active_fit = self.sm.active_fit_or_none
663
664
  if active_fit is None:
664
665
  ui.notify("No active fit to export.", type="negative")
@@ -684,5 +685,5 @@ class BayesPanel(BaseComponent):
684
685
  buf.seek(0)
685
686
 
686
687
  zip_filename = f"{stem}_mcmc_notebook.zip"
687
- ui.download.content(buf.read(), filename=zip_filename)
688
+ await custom_download(buf.read(), filename=zip_filename)
688
689
  ui.notify(f"Notebook exported as {zip_filename}.", type="positive")
@@ -4,6 +4,7 @@ import numpy as np
4
4
  import pandas as pd
5
5
  from ..classes import Component
6
6
  from .graph import Graph
7
+ from ..utils import custom_download
7
8
 
8
9
  from nicegui.events import UploadEventArguments
9
10
  from collections import Counter
@@ -127,9 +128,12 @@ class DataGenerationPanel(BaseComponent):
127
128
  template_text += "\nmM" + ",mM" * (len(self.sm.components) - 1) # Header row with units
128
129
  template_text += "\n" + "0.001" + ",0" * (len(self.sm.components) - 1) # Add a newline after the header
129
130
 
131
+ async def download_template():
132
+ await custom_download(template_text, "component_concentrations_template.csv")
133
+
130
134
  ui.button(
131
135
  "Download template CSV",
132
- on_click=lambda: ui.download.content(template_text, "component_concentrations_template.csv"),
136
+ on_click=download_template,
133
137
  )
134
138
 
135
139
  ui.label("""
@@ -12,7 +12,7 @@ import bindtools.binding as bd
12
12
  from .base import BaseComponent
13
13
  from .graph import Graph
14
14
  from ..classes import FitResult
15
- from ..utils import safe_filename, _infer_simple_fast_exchange_topology
15
+ from ..utils import safe_filename, _infer_simple_fast_exchange_topology, custom_download
16
16
  from functools import partial
17
17
  from typing import cast
18
18
 
@@ -647,7 +647,7 @@ class FittingPanel(BaseComponent):
647
647
  self.sm.active_fit.name = self.fit_name_input.value
648
648
  self.sm.active_fit.description = self.fit_comment_input.value
649
649
 
650
- def download_fit_data_csv(self) -> None:
650
+ async def download_fit_data_csv(self) -> None:
651
651
  if self.sm.active_fit_id is None:
652
652
  ui.notify("No active fit to download.", type="negative")
653
653
  return
@@ -685,10 +685,10 @@ class FittingPanel(BaseComponent):
685
685
  export_df = pd.concat(export_frames, axis=1)
686
686
  filename = f"fit_{safe_filename(fit.name, fallback='fit')}_data.csv"
687
687
  csv = export_df.to_csv(index=False, encoding="utf-8", float_format="{:.5e}".format)
688
- ui.download.content(csv, filename=filename)
688
+ await custom_download(csv, filename=filename)
689
689
  ui.notify(f"Fit data downloaded as {filename}.", type="info")
690
690
 
691
- def download_fit_notebook(self) -> None:
691
+ async def download_fit_notebook(self) -> None:
692
692
  """Export the active fit as a zip containing a Jupyter notebook and a data CSV."""
693
693
  if self.sm.active_fit_id is None:
694
694
  ui.notify("No active fit to export.", type="negative")
@@ -712,7 +712,7 @@ class FittingPanel(BaseComponent):
712
712
  buf.seek(0)
713
713
 
714
714
  zip_filename = f"{stem}_notebook.zip"
715
- ui.download.content(buf.read(), filename=zip_filename)
715
+ await custom_download(buf.read(), filename=zip_filename)
716
716
  ui.notify(f"Notebook exported as {zip_filename}.", type="positive")
717
717
 
718
718
 
@@ -1,10 +1,10 @@
1
1
  import json
2
2
 
3
- from nicegui import ui
3
+ from nicegui import ui, app
4
4
 
5
5
  from .base import BaseComponent
6
6
  from ..classes import Simulation, FitResult
7
- from ..utils import safe_filename
7
+ from ..utils import safe_filename, custom_download
8
8
  import pandas as pd
9
9
  import uuid
10
10
 
@@ -399,7 +399,39 @@ class Graph(BaseComponent):
399
399
  ui.notify(f"No plotted data available for {filename}.", type="warning")
400
400
  return
401
401
 
402
- js = f"""
402
+ is_native = False
403
+ try:
404
+ if getattr(app.native, "main_window", None) is not None:
405
+ is_native = True
406
+ except Exception:
407
+ pass
408
+
409
+ if is_native:
410
+ js = f"""
411
+ const root = getElement({self.graph.id});
412
+ const container = root?.$el ?? root;
413
+ const plot = container?.querySelector('.js-plotly-plot') ?? container;
414
+ if (!plot || typeof Plotly === 'undefined') {{
415
+ null;
416
+ }} else {{
417
+ Plotly.toImage(plot, {{format: 'png', scale: 2}});
418
+ }}
419
+ """
420
+ data_url = await ui.run_javascript(js)
421
+ if not data_url or not data_url.startswith("data:image/png;base64,"):
422
+ ui.notify(f"Unable to export {filename}.png", type="negative")
423
+ return
424
+
425
+ try:
426
+ import base64
427
+
428
+ header, encoded = data_url.split(",", 1)
429
+ image_bytes = base64.b64decode(encoded)
430
+ await custom_download(image_bytes, filename=f"{filename}.png")
431
+ except Exception as e:
432
+ ui.notify(f"Failed to export {filename}.png: {str(e)}", type="negative")
433
+ else:
434
+ js = f"""
403
435
  (() => {{
404
436
  const root = getElement({self.graph.id});
405
437
  const container = root?.$el ?? root;
@@ -409,9 +441,9 @@ class Graph(BaseComponent):
409
441
  return true;
410
442
  }})()
411
443
  """
412
- ok = await ui.run_javascript(js)
413
- if not ok:
414
- ui.notify(f"Unable to export {filename}.png", type="negative")
444
+ ok = await ui.run_javascript(js)
445
+ if not ok:
446
+ ui.notify(f"Unable to export {filename}.png", type="negative")
415
447
 
416
448
  def update_plot_compfree(self, e):
417
449
  """Update the plot to show or hide component free concentrations."""
@@ -5,7 +5,7 @@ from nicegui import run, ui
5
5
  import bindtools.binding as bd
6
6
 
7
7
  from ..classes import Simulation
8
- from ..utils import safe_filename
8
+ from ..utils import safe_filename, custom_download
9
9
  from .base import BaseComponent
10
10
  from .graph import Graph
11
11
 
@@ -50,7 +50,7 @@ class SimulationPanel(BaseComponent):
50
50
  self.save_sim_details_button.set_enabled(False)
51
51
  self.download_sim_button = ui.button(
52
52
  "Download Simulation Data",
53
- on_click=lambda: self.download_sim_data(),
53
+ on_click=self.download_sim_data,
54
54
  ).classes("ml-4")
55
55
  self.export_sim_notebook_button = ui.button(
56
56
  "Export to Notebook",
@@ -325,7 +325,7 @@ class SimulationPanel(BaseComponent):
325
325
  """Delete a simulation and update the UI."""
326
326
  self.sm.delete_simulation(sim)
327
327
 
328
- def download_sim_data(self):
328
+ async def download_sim_data(self):
329
329
  """Download the current simulation data as a CSV file."""
330
330
  active_sim = self.sm.active_sim_or_none
331
331
  if active_sim is None:
@@ -335,10 +335,10 @@ class SimulationPanel(BaseComponent):
335
335
  sim_data = active_sim.results
336
336
  csv = sim_data.to_csv(index=False, encoding="utf-8", float_format="{:.5e}".format)
337
337
  filename = f"simulation_{safe_filename(active_sim.name, fallback='simulation')}_data.csv"
338
- ui.download.content(csv, filename=filename)
338
+ await custom_download(csv, filename=filename)
339
339
  ui.notify(f"Simulation data downloaded as {filename}.", type="info")
340
340
 
341
- def download_sim_notebook(self) -> None:
341
+ async def download_sim_notebook(self) -> None:
342
342
  """Export the active simulation as a Jupyter notebook (.ipynb) and download it."""
343
343
  active_sim = self.sm.active_sim_or_none
344
344
  if active_sim is None:
@@ -354,5 +354,5 @@ class SimulationPanel(BaseComponent):
354
354
  stem = safe_filename(active_sim.name, fallback="simulation")
355
355
  filename = f"{stem}.ipynb"
356
356
  content = json.dumps(notebook, indent=1)
357
- ui.download.content(content, filename=filename)
357
+ await custom_download(content, filename=filename)
358
358
  ui.notify(f"Notebook exported as {filename}.", type="positive")
@@ -27,7 +27,7 @@ from ..classes import (
27
27
  MCMCSim,
28
28
  UIBindings,
29
29
  )
30
- from ..utils import eq_mat_from_equation_str_infer_components
30
+ from ..utils import eq_mat_from_equation_str_infer_components, custom_download
31
31
  from lmfit import Parameter as LMFitParameter
32
32
  import logging
33
33
 
@@ -1048,7 +1048,7 @@ class StateManager:
1048
1048
  # Ensure data is flushed and gzip stream is closed before reading
1049
1049
  buffer.seek(0)
1050
1050
  filename = f"bindtools_project_{timestamp}.json.gz"
1051
- ui.download.content(buffer.read(), filename=filename)
1051
+ await custom_download(buffer.read(), filename=filename)
1052
1052
  ui.notify(f"Project saved as {filename}", type="info")
1053
1053
 
1054
1054
  def to_json(self):
@@ -326,3 +326,55 @@ def _infer_simple_fast_exchange_topology(eq_mat: np.ndarray, n_comp: int) -> tup
326
326
  if len(bound_indices) == 2 and sigs == {(1, 1), (2, 1)}:
327
327
  return "2:1", [sig_to_idx[(1, 1)], sig_to_idx[(2, 1)]]
328
328
  return None
329
+
330
+
331
+ async def custom_download(content: bytes | str, filename: str) -> None:
332
+ """Download content. If in native mode, open a save dialog; otherwise, use ui.download.content."""
333
+ from nicegui import ui, app
334
+
335
+ # Check if the content is a file-like object and read it
336
+ if hasattr(content, "read"):
337
+ content = content.read()
338
+ elif hasattr(content, "getvalue"):
339
+ content = content.getvalue()
340
+
341
+ # Check if we are running in native mode
342
+ is_native = False
343
+ try:
344
+ if getattr(app.native, "main_window", None) is not None:
345
+ is_native = True
346
+ except Exception:
347
+ pass
348
+
349
+ if is_native:
350
+ try:
351
+ import webview
352
+
353
+ save_dialog_type = getattr(webview, "SAVE_DIALOG", 30)
354
+ if hasattr(webview, "FileDialog") and hasattr(webview.FileDialog, "SAVE"):
355
+ save_dialog_type = webview.FileDialog.SAVE
356
+
357
+ # app.native.main_window is an async wrapper around pywebview window
358
+ selected = await app.native.main_window.create_file_dialog(
359
+ dialog_type=save_dialog_type, save_filename=filename
360
+ )
361
+ if selected:
362
+ filepath = selected[0] if isinstance(selected, (list, tuple)) else selected
363
+ if isinstance(content, str):
364
+ with open(filepath, "w", encoding="utf-8") as f:
365
+ f.write(content)
366
+ else:
367
+ with open(filepath, "wb") as f:
368
+ f.write(content)
369
+ ui.notify(f"File saved to {filepath}", type="positive")
370
+ else:
371
+ ui.notify("Save cancelled", type="warning")
372
+ except Exception as e:
373
+ ui.notify(f"Failed to save file: {str(e)}", type="negative")
374
+ else:
375
+ # Standard browser download
376
+ if isinstance(content, str):
377
+ content_bytes = content.encode("utf-8")
378
+ else:
379
+ content_bytes = content
380
+ ui.download.content(content_bytes, filename=filename)
File without changes
File without changes
File without changes