figrecipe 0.6.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

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 (269) hide show
  1. figrecipe/__init__.py +161 -1030
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/__init__.py +48 -0
  4. figrecipe/_api/_extract.py +108 -0
  5. figrecipe/_api/_notebook.py +61 -0
  6. figrecipe/_api/_panel.py +113 -0
  7. figrecipe/_api/_save.py +287 -0
  8. figrecipe/_api/_seaborn_proxy.py +34 -0
  9. figrecipe/_api/_style_manager.py +153 -0
  10. figrecipe/_api/_subplots.py +333 -0
  11. figrecipe/_api/_validate.py +82 -0
  12. figrecipe/_cli/__init__.py +7 -0
  13. figrecipe/_cli/_compose.py +87 -0
  14. figrecipe/_cli/_convert.py +117 -0
  15. figrecipe/_cli/_crop.py +82 -0
  16. figrecipe/_cli/_edit.py +70 -0
  17. figrecipe/_cli/_extract.py +128 -0
  18. figrecipe/_cli/_fonts.py +47 -0
  19. figrecipe/_cli/_info.py +67 -0
  20. figrecipe/_cli/_main.py +58 -0
  21. figrecipe/_cli/_reproduce.py +79 -0
  22. figrecipe/_cli/_style.py +77 -0
  23. figrecipe/_cli/_validate.py +66 -0
  24. figrecipe/_cli/_version.py +50 -0
  25. figrecipe/_composition/__init__.py +32 -0
  26. figrecipe/_composition/_alignment.py +452 -0
  27. figrecipe/_composition/_compose.py +179 -0
  28. figrecipe/_composition/_import_axes.py +127 -0
  29. figrecipe/_composition/_visibility.py +125 -0
  30. figrecipe/_dev/__init__.py +4 -93
  31. figrecipe/_dev/_plotters.py +76 -0
  32. figrecipe/_dev/_run_demos.py +56 -0
  33. figrecipe/_dev/browser/__init__.py +69 -0
  34. figrecipe/_dev/browser/_audio.py +240 -0
  35. figrecipe/_dev/browser/_caption.py +356 -0
  36. figrecipe/_dev/browser/_click_effect.py +146 -0
  37. figrecipe/_dev/browser/_cursor.py +196 -0
  38. figrecipe/_dev/browser/_highlight.py +105 -0
  39. figrecipe/_dev/browser/_narration.py +237 -0
  40. figrecipe/_dev/browser/_recorder.py +446 -0
  41. figrecipe/_dev/browser/_utils.py +178 -0
  42. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  43. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  44. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  45. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  46. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  47. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  48. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  49. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  50. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  51. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  53. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  55. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  56. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  57. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  58. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  59. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  60. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  61. figrecipe/_editor/__init__.py +61 -13
  62. figrecipe/_editor/_bbox/__init__.py +43 -0
  63. figrecipe/_editor/_bbox/_collections.py +177 -0
  64. figrecipe/_editor/_bbox/_elements.py +159 -0
  65. figrecipe/_editor/_bbox/_extract.py +402 -0
  66. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  67. figrecipe/_editor/_bbox/_extract_text.py +466 -0
  68. figrecipe/_editor/_bbox/_lines.py +173 -0
  69. figrecipe/_editor/_bbox/_transforms.py +146 -0
  70. figrecipe/_editor/_call_overrides.py +183 -0
  71. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  72. figrecipe/_editor/_figure_layout.py +211 -0
  73. figrecipe/_editor/_flask_app.py +200 -1030
  74. figrecipe/_editor/_helpers.py +251 -0
  75. figrecipe/_editor/_hitmap/__init__.py +76 -0
  76. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  77. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  78. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  79. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  80. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  81. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  82. figrecipe/_editor/_hitmap/_colors.py +181 -0
  83. figrecipe/_editor/_hitmap/_detect.py +194 -0
  84. figrecipe/_editor/_hitmap/_restore.py +154 -0
  85. figrecipe/_editor/_hitmap_main.py +182 -0
  86. figrecipe/_editor/_overrides.py +4 -1
  87. figrecipe/_editor/_plot_types_registry.py +190 -0
  88. figrecipe/_editor/_preferences.py +135 -0
  89. figrecipe/_editor/_render_overrides.py +507 -0
  90. figrecipe/_editor/_renderer.py +81 -186
  91. figrecipe/_editor/_routes_annotation.py +114 -0
  92. figrecipe/_editor/_routes_axis.py +482 -0
  93. figrecipe/_editor/_routes_captions.py +130 -0
  94. figrecipe/_editor/_routes_composition.py +270 -0
  95. figrecipe/_editor/_routes_core.py +126 -0
  96. figrecipe/_editor/_routes_datatable.py +364 -0
  97. figrecipe/_editor/_routes_element.py +335 -0
  98. figrecipe/_editor/_routes_files.py +443 -0
  99. figrecipe/_editor/_routes_image.py +200 -0
  100. figrecipe/_editor/_routes_snapshot.py +94 -0
  101. figrecipe/_editor/_routes_style.py +243 -0
  102. figrecipe/_editor/_templates/__init__.py +116 -1
  103. figrecipe/_editor/_templates/_html.py +154 -64
  104. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  105. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  106. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  107. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  108. figrecipe/_editor/_templates/_scripts/__init__.py +178 -0
  109. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  110. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  111. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  112. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  113. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  114. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  115. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  116. figrecipe/_editor/_templates/_scripts/_core.py +493 -0
  117. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  118. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  119. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  120. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  121. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  122. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  123. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  124. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  125. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  126. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  127. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  128. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  129. figrecipe/_editor/_templates/_scripts/_element_editor.py +325 -0
  130. figrecipe/_editor/_templates/_scripts/_files.py +429 -0
  131. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  132. figrecipe/_editor/_templates/_scripts/_hitmap.py +512 -0
  133. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  134. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  135. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  136. figrecipe/_editor/_templates/_scripts/_legend_drag.py +270 -0
  137. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  138. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  139. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  140. figrecipe/_editor/_templates/_scripts/_panel_drag.py +505 -0
  141. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  142. figrecipe/_editor/_templates/_scripts/_panel_position.py +463 -0
  143. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  144. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  145. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  146. figrecipe/_editor/_templates/_scripts/_selection.py +244 -0
  147. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  148. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  149. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  150. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  151. figrecipe/_editor/_templates/_scripts/_zoom.py +212 -0
  152. figrecipe/_editor/_templates/_styles/__init__.py +78 -0
  153. figrecipe/_editor/_templates/_styles/_base.py +111 -0
  154. figrecipe/_editor/_templates/_styles/_buttons.py +327 -0
  155. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  156. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  157. figrecipe/_editor/_templates/_styles/_controls.py +430 -0
  158. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  159. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  160. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  161. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  162. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  163. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  164. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  165. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  166. figrecipe/_editor/_templates/_styles/_forms.py +224 -0
  167. figrecipe/_editor/_templates/_styles/_hitmap.py +191 -0
  168. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  169. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  170. figrecipe/_editor/_templates/_styles/_modals.py +127 -0
  171. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  172. figrecipe/_editor/_templates/_styles/_preview.py +430 -0
  173. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  174. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  175. figrecipe/_editor/static/audio/click.mp3 +0 -0
  176. figrecipe/_editor/static/click.mp3 +0 -0
  177. figrecipe/_editor/static/icons/favicon.ico +0 -0
  178. figrecipe/_integrations/__init__.py +17 -0
  179. figrecipe/_integrations/_scitex_stats.py +298 -0
  180. figrecipe/_params/_DECORATION_METHODS.py +8 -0
  181. figrecipe/_recorder.py +63 -109
  182. figrecipe/_recorder_utils.py +124 -0
  183. figrecipe/_reproducer/__init__.py +18 -0
  184. figrecipe/_reproducer/_core.py +509 -0
  185. figrecipe/_reproducer/_custom_plots.py +279 -0
  186. figrecipe/_reproducer/_seaborn.py +100 -0
  187. figrecipe/_reproducer/_violin.py +186 -0
  188. figrecipe/_signatures/_kwargs.py +273 -0
  189. figrecipe/_signatures/_loader.py +21 -423
  190. figrecipe/_signatures/_parsing.py +147 -0
  191. figrecipe/_utils/__init__.py +3 -0
  192. figrecipe/_utils/_bundle.py +205 -0
  193. figrecipe/_wrappers/_axes.py +252 -895
  194. figrecipe/_wrappers/_axes_helpers.py +136 -0
  195. figrecipe/_wrappers/_axes_plots.py +418 -0
  196. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  197. figrecipe/_wrappers/_caption_generator.py +218 -0
  198. figrecipe/_wrappers/_figure.py +188 -1
  199. figrecipe/_wrappers/_panel_labels.py +127 -0
  200. figrecipe/_wrappers/_plot_helpers.py +143 -0
  201. figrecipe/_wrappers/_stat_annotation.py +274 -0
  202. figrecipe/_wrappers/_violin_helpers.py +180 -0
  203. figrecipe/styles/__init__.py +8 -6
  204. figrecipe/styles/_dotdict.py +72 -0
  205. figrecipe/styles/_finalize.py +134 -0
  206. figrecipe/styles/_fonts.py +77 -0
  207. figrecipe/styles/_kwargs_converter.py +178 -0
  208. figrecipe/styles/_plot_styles.py +209 -0
  209. figrecipe/styles/_style_applier.py +42 -480
  210. figrecipe/styles/_style_loader.py +16 -192
  211. figrecipe/styles/_themes.py +151 -0
  212. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  213. figrecipe/styles/presets/SCITEX.yaml +40 -28
  214. figrecipe-0.9.0.dist-info/METADATA +427 -0
  215. figrecipe-0.9.0.dist-info/RECORD +277 -0
  216. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  217. figrecipe/_editor/_bbox.py +0 -978
  218. figrecipe/_editor/_hitmap.py +0 -937
  219. figrecipe/_editor/_templates/_scripts.py +0 -2778
  220. figrecipe/_editor/_templates/_styles.py +0 -1326
  221. figrecipe/_reproducer.py +0 -975
  222. figrecipe-0.6.0.dist-info/METADATA +0 -394
  223. figrecipe-0.6.0.dist-info/RECORD +0 -90
  224. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  225. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  226. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  227. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  228. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  229. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  230. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  231. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  232. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  233. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  234. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  235. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  236. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  237. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  238. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  239. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  240. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  241. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  242. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  243. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  244. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  245. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  246. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  247. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  248. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  249. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  250. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  251. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  252. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  253. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  254. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  255. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  256. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  257. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  258. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  259. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  260. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  261. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  262. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  263. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  264. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  265. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  266. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  267. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  268. {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  269. {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ File management routes for the figure editor.
5
+ Handles file listing, switching, creation, deletion, renaming, duplication, and download.
6
+ """
7
+
8
+ from flask import jsonify, request, send_file
9
+
10
+
11
+ def register_file_routes(app, editor):
12
+ """Register file management routes with the Flask app."""
13
+ from pathlib import Path
14
+
15
+ from ._helpers import render_with_overrides
16
+ from ._hitmap import generate_hitmap, hitmap_to_base64
17
+
18
+ @app.route("/api/files")
19
+ def list_files():
20
+ """List available recipe files in working directory as a tree structure."""
21
+ working_dir = getattr(editor, "working_dir", Path.cwd())
22
+
23
+ def build_tree(directory: Path, relative_base: Path = None) -> list:
24
+ """Recursively build tree structure from directory."""
25
+ if relative_base is None:
26
+ relative_base = directory
27
+
28
+ items = []
29
+
30
+ try:
31
+ entries = sorted(
32
+ directory.iterdir(),
33
+ key=lambda x: (not x.is_dir(), x.name.lower()),
34
+ )
35
+ except PermissionError:
36
+ return items
37
+
38
+ for entry in entries:
39
+ if entry.name.startswith("."):
40
+ continue
41
+ if entry.name.endswith(".overrides.yaml"):
42
+ continue
43
+ if entry.name == "__pycache__":
44
+ continue
45
+
46
+ rel_path = str(entry.relative_to(relative_base))
47
+
48
+ if entry.is_dir():
49
+ children = build_tree(entry, relative_base)
50
+ if children:
51
+ items.append(
52
+ {
53
+ "path": rel_path,
54
+ "name": entry.name,
55
+ "type": "directory",
56
+ "children": children,
57
+ }
58
+ )
59
+ elif entry.suffix.lower() in (".yaml", ".yml"):
60
+ png_path = entry.with_suffix(".png")
61
+ has_png = png_path.exists()
62
+
63
+ items.append(
64
+ {
65
+ "path": rel_path,
66
+ "name": entry.stem,
67
+ "type": "file",
68
+ "has_image": has_png,
69
+ "is_current": (
70
+ editor.recipe_path
71
+ and entry.resolve() == editor.recipe_path.resolve()
72
+ ),
73
+ }
74
+ )
75
+
76
+ return items
77
+
78
+ tree = build_tree(working_dir)
79
+
80
+ flat_files = []
81
+
82
+ def flatten_tree(items):
83
+ for item in items:
84
+ if item["type"] == "directory":
85
+ flatten_tree(item.get("children", []))
86
+ else:
87
+ flat_files.append(item)
88
+
89
+ flatten_tree(tree)
90
+
91
+ return jsonify(
92
+ {
93
+ "tree": tree,
94
+ "files": flat_files,
95
+ "working_dir": str(working_dir),
96
+ "current_file": (
97
+ str(editor.recipe_path.name) if editor.recipe_path else None
98
+ ),
99
+ }
100
+ )
101
+
102
+ @app.route("/api/switch", methods=["POST"])
103
+ def switch_file():
104
+ """Switch to a different recipe file."""
105
+ from .._reproducer import reproduce
106
+ from .._wrappers._figure import RecordingFigure
107
+
108
+ data = request.get_json() or {}
109
+ file_path = data.get("path")
110
+
111
+ if not file_path:
112
+ return jsonify({"error": "No file path provided"}), 400
113
+
114
+ working_dir = getattr(editor, "working_dir", Path.cwd())
115
+ full_path = working_dir / file_path
116
+
117
+ if not full_path.exists():
118
+ return jsonify({"error": f"File not found: {file_path}"}), 404
119
+
120
+ try:
121
+ fig, axes = reproduce(full_path)
122
+
123
+ if not isinstance(fig, RecordingFigure):
124
+ from .._recorder import FigureRecord, Recorder
125
+
126
+ wrapped_fig = RecordingFigure.__new__(RecordingFigure)
127
+ wrapped_fig._fig = fig
128
+ wrapped_fig._axes = [[ax] for ax in fig.axes]
129
+ wrapped_fig._recorder = Recorder()
130
+ wrapped_fig._recorder._figure_record = FigureRecord(
131
+ figsize=tuple(fig.get_size_inches()),
132
+ dpi=int(fig.dpi),
133
+ )
134
+ fig = wrapped_fig
135
+
136
+ editor.fig = fig
137
+ editor.recipe_path = full_path
138
+ editor._hitmap_generated = False
139
+ editor._color_map = {}
140
+
141
+ editor._init_style_overrides(None)
142
+
143
+ hitmap_img, editor._color_map = generate_hitmap(editor.fig)
144
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
145
+ editor._hitmap_generated = True
146
+
147
+ base64_img, bboxes, img_size = render_with_overrides(
148
+ editor.fig,
149
+ editor.get_effective_style(),
150
+ editor.dark_mode,
151
+ )
152
+
153
+ return jsonify(
154
+ {
155
+ "success": True,
156
+ "image": base64_img,
157
+ "bboxes": bboxes,
158
+ "color_map": editor._color_map,
159
+ "img_size": {"width": img_size[0], "height": img_size[1]},
160
+ "file": file_path,
161
+ }
162
+ )
163
+
164
+ except Exception as e:
165
+ return jsonify({"error": str(e)}), 500
166
+
167
+ @app.route("/api/new", methods=["POST"])
168
+ def new_figure():
169
+ """Create a new blank figure and save it as a physical file."""
170
+ from .. import reproduce, save, subplots
171
+ from .._wrappers._figure import RecordingFigure
172
+
173
+ try:
174
+ fig, ax = subplots()
175
+ ax.set_title("New Figure")
176
+
177
+ working_dir = getattr(editor, "working_dir", Path.cwd())
178
+ base_name = "new_figure"
179
+ counter = 1
180
+ while True:
181
+ file_path = working_dir / f"{base_name}_{counter:03d}.yaml"
182
+ if not file_path.exists():
183
+ break
184
+ counter += 1
185
+
186
+ png_path = file_path.with_suffix(".png")
187
+ save(fig, png_path, validate=False, verbose=False)
188
+
189
+ reproduced_fig, axes = reproduce(file_path)
190
+
191
+ if not isinstance(reproduced_fig, RecordingFigure):
192
+ from .._recorder import FigureRecord, Recorder
193
+
194
+ wrapped_fig = RecordingFigure.__new__(RecordingFigure)
195
+ wrapped_fig._fig = reproduced_fig
196
+ wrapped_fig._axes = (
197
+ [[ax] for ax in reproduced_fig.axes]
198
+ if hasattr(reproduced_fig, "axes")
199
+ else [[axes]]
200
+ )
201
+ wrapped_fig._recorder = Recorder()
202
+ wrapped_fig._recorder._figure_record = FigureRecord(
203
+ figsize=tuple(reproduced_fig.get_size_inches()),
204
+ dpi=int(reproduced_fig.dpi),
205
+ )
206
+ reproduced_fig = wrapped_fig
207
+
208
+ editor.fig = reproduced_fig
209
+ editor.recipe_path = file_path
210
+ editor._hitmap_generated = False
211
+ editor._color_map = {}
212
+
213
+ editor._init_style_overrides(None)
214
+
215
+ hitmap_img, editor._color_map = generate_hitmap(editor.fig)
216
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
217
+ editor._hitmap_generated = True
218
+
219
+ base64_img, bboxes, img_size = render_with_overrides(
220
+ editor.fig,
221
+ editor.get_effective_style(),
222
+ editor.dark_mode,
223
+ )
224
+
225
+ return jsonify(
226
+ {
227
+ "success": True,
228
+ "image": base64_img,
229
+ "bboxes": bboxes,
230
+ "color_map": editor._color_map,
231
+ "img_size": {"width": img_size[0], "height": img_size[1]},
232
+ "file": str(file_path.relative_to(working_dir)),
233
+ "file_name": file_path.stem,
234
+ }
235
+ )
236
+
237
+ except Exception as e:
238
+ import traceback
239
+
240
+ traceback.print_exc()
241
+ return jsonify({"error": str(e)}), 500
242
+
243
+ @app.route("/api/delete", methods=["POST"])
244
+ def delete_figure():
245
+ """Delete a figure file and its associated files."""
246
+ data = request.get_json() or {}
247
+ file_path = data.get("path")
248
+
249
+ if not file_path:
250
+ return jsonify({"error": "No file path provided"}), 400
251
+
252
+ working_dir = getattr(editor, "working_dir", Path.cwd())
253
+ full_path = working_dir / file_path
254
+
255
+ if full_path.suffix.lower() in (".yaml", ".yml"):
256
+ base_path = full_path.with_suffix("")
257
+ elif full_path.suffix.lower() == ".png":
258
+ base_path = full_path.with_suffix("")
259
+ else:
260
+ return jsonify({"error": "Invalid file type"}), 400
261
+
262
+ is_current = editor.recipe_path and base_path == editor.recipe_path.with_suffix(
263
+ ""
264
+ )
265
+
266
+ deleted_files = []
267
+ errors = []
268
+
269
+ for ext in [".yaml", ".yml", ".png", ".overrides.yaml"]:
270
+ target = base_path.with_suffix(ext)
271
+ if target.exists():
272
+ try:
273
+ target.unlink()
274
+ deleted_files.append(target.name)
275
+ except Exception as e:
276
+ errors.append(f"{target.name}: {e}")
277
+
278
+ if not deleted_files:
279
+ return jsonify({"error": "No files found to delete"}), 404
280
+
281
+ switch_to = None
282
+ if is_current:
283
+ for pattern in ["*.yaml", "*.yml"]:
284
+ for f in working_dir.glob(pattern):
285
+ if not f.name.startswith(".") and not f.name.endswith(
286
+ ".overrides.yaml"
287
+ ):
288
+ switch_to = str(f.relative_to(working_dir))
289
+ break
290
+ if switch_to:
291
+ break
292
+
293
+ return jsonify(
294
+ {
295
+ "success": True,
296
+ "deleted": deleted_files,
297
+ "errors": errors if errors else None,
298
+ "was_current": is_current,
299
+ "switch_to": switch_to,
300
+ }
301
+ )
302
+
303
+ @app.route("/api/rename", methods=["POST"])
304
+ def rename_figure():
305
+ """Rename a figure file and its associated files."""
306
+ data = request.get_json() or {}
307
+ old_path = data.get("path")
308
+ new_name = data.get("new_name")
309
+
310
+ if not old_path:
311
+ return jsonify({"error": "No file path provided"}), 400
312
+ if not new_name:
313
+ return jsonify({"error": "No new name provided"}), 400
314
+
315
+ new_name = Path(new_name).stem
316
+ if not new_name or "/" in new_name or "\\" in new_name:
317
+ return jsonify({"error": "Invalid new name"}), 400
318
+
319
+ working_dir = getattr(editor, "working_dir", Path.cwd())
320
+ full_path = working_dir / old_path
321
+
322
+ if full_path.suffix.lower() in (".yaml", ".yml"):
323
+ old_base = full_path.with_suffix("")
324
+ elif full_path.suffix.lower() == ".png":
325
+ old_base = full_path.with_suffix("")
326
+ else:
327
+ return jsonify({"error": "Invalid file type"}), 400
328
+
329
+ new_base = working_dir / new_name
330
+
331
+ for ext in [".yaml", ".png"]:
332
+ if new_base.with_suffix(ext).exists():
333
+ return jsonify({"error": f"File {new_name}{ext} already exists"}), 400
334
+
335
+ renamed_files = []
336
+ errors = []
337
+
338
+ for ext in [".yaml", ".yml", ".png", ".overrides.yaml"]:
339
+ old_file = old_base.with_suffix(ext)
340
+ new_file = new_base.with_suffix(ext)
341
+ if old_file.exists():
342
+ try:
343
+ old_file.rename(new_file)
344
+ renamed_files.append({"from": old_file.name, "to": new_file.name})
345
+ except Exception as e:
346
+ errors.append(f"{old_file.name}: {e}")
347
+
348
+ if not renamed_files:
349
+ return jsonify({"error": "No files found to rename"}), 404
350
+
351
+ if editor.recipe_path and old_base == editor.recipe_path.with_suffix(""):
352
+ editor.recipe_path = new_base.with_suffix(".yaml")
353
+
354
+ return jsonify(
355
+ {
356
+ "success": True,
357
+ "renamed": renamed_files,
358
+ "new_name": new_name,
359
+ "errors": errors if errors else None,
360
+ }
361
+ )
362
+
363
+ @app.route("/api/duplicate", methods=["POST"])
364
+ def duplicate_figure():
365
+ """Duplicate a figure file and its associated files."""
366
+ import shutil
367
+
368
+ data = request.get_json() or {}
369
+ file_path = data.get("path")
370
+
371
+ if not file_path:
372
+ return jsonify({"error": "No file path provided"}), 400
373
+
374
+ working_dir = getattr(editor, "working_dir", Path.cwd())
375
+ full_path = working_dir / file_path
376
+
377
+ if full_path.suffix.lower() in (".yaml", ".yml"):
378
+ base_path = full_path.with_suffix("")
379
+ elif full_path.suffix.lower() == ".png":
380
+ base_path = full_path.with_suffix("")
381
+ else:
382
+ return jsonify({"error": "Invalid file type"}), 400
383
+
384
+ original_name = base_path.stem
385
+ counter = 1
386
+ while True:
387
+ new_name = f"{original_name}_copy_{counter:02d}"
388
+ new_base = base_path.parent / new_name
389
+ if not new_base.with_suffix(".yaml").exists():
390
+ break
391
+ counter += 1
392
+
393
+ copied_files = []
394
+ errors = []
395
+
396
+ for ext in [".yaml", ".yml", ".png", ".overrides.yaml"]:
397
+ old_file = base_path.with_suffix(ext)
398
+ new_file = new_base.with_suffix(ext)
399
+ if old_file.exists():
400
+ try:
401
+ shutil.copy2(old_file, new_file)
402
+ copied_files.append({"from": old_file.name, "to": new_file.name})
403
+ except Exception as e:
404
+ errors.append(f"{old_file.name}: {e}")
405
+
406
+ if not copied_files:
407
+ return jsonify({"error": "No files found to duplicate"}), 404
408
+
409
+ return jsonify(
410
+ {
411
+ "success": True,
412
+ "copied": copied_files,
413
+ "new_name": new_name,
414
+ "new_path": str(new_base.with_suffix(".yaml").relative_to(working_dir)),
415
+ "errors": errors if errors else None,
416
+ }
417
+ )
418
+
419
+ @app.route("/api/download")
420
+ def download_figure():
421
+ """Download a figure file (YAML recipe)."""
422
+ file_path = request.args.get("path")
423
+
424
+ if not file_path:
425
+ return jsonify({"error": "No file path provided"}), 400
426
+
427
+ working_dir = getattr(editor, "working_dir", Path.cwd())
428
+ full_path = working_dir / file_path
429
+
430
+ if full_path.suffix.lower() not in (".yaml", ".yml"):
431
+ yaml_path = full_path.with_suffix(".yaml")
432
+ if yaml_path.exists():
433
+ full_path = yaml_path
434
+ else:
435
+ return jsonify({"error": "File not found"}), 404
436
+
437
+ if not full_path.exists():
438
+ return jsonify({"error": "File not found"}), 404
439
+
440
+ return send_file(full_path, as_attachment=True, download_name=full_path.name)
441
+
442
+
443
+ __all__ = ["register_file_routes"]
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Image drop Flask route handlers for the figure editor.
4
+
5
+ Handles drag & drop of external images to create imshow panels.
6
+ """
7
+
8
+ import base64
9
+ import io
10
+ import urllib.request
11
+
12
+ import numpy as np
13
+ from flask import jsonify, request
14
+ from PIL import Image
15
+
16
+ from ._helpers import render_with_overrides
17
+
18
+
19
+ def _add_image_panel_to_figure(editor, img_array, filename, drop_x, drop_y):
20
+ """Add an image panel using the figrecipe recording system.
21
+
22
+ This ensures dropped images become proper panels (C, D, E...) that
23
+ integrate with the existing coordinate system and pipeline.
24
+ """
25
+ from .._wrappers._axes import RecordingAxes
26
+
27
+ # Get the underlying matplotlib figure
28
+ mpl_fig = editor.fig.fig if hasattr(editor.fig, "fig") else editor.fig
29
+
30
+ # Calculate panel position based on drop location
31
+ # Default size: 40% of figure in each dimension
32
+ panel_width = 0.4
33
+ panel_height = 0.4
34
+
35
+ # Convert drop position to axes position (bottom-left origin for matplotlib)
36
+ left = max(0.05, min(0.55, drop_x - panel_width / 2))
37
+ bottom = max(0.05, min(0.55, (1 - drop_y) - panel_height / 2))
38
+
39
+ # Add new axes at the drop position
40
+ mpl_ax = mpl_fig.add_axes([left, bottom, panel_width, panel_height])
41
+
42
+ # If we have a RecordingFigure with recorder, wrap the axes properly
43
+ if hasattr(editor.fig, "_recorder"):
44
+ recorder = editor.fig._recorder
45
+ # Determine position index for the new panel
46
+ existing_axes = len(mpl_fig.get_axes()) - 1 # -1 because we just added one
47
+ position = (existing_axes, 0) # Simple sequential positioning
48
+
49
+ # Create RecordingAxes wrapper
50
+ wrapped_ax = RecordingAxes(mpl_ax, recorder, position=position)
51
+
52
+ # Call imshow through wrapper so it gets recorded
53
+ wrapped_ax.imshow(img_array, id=f"dropped_{filename[:15]}")
54
+ wrapped_ax.set_title(filename[:20])
55
+ wrapped_ax.axis("off")
56
+
57
+ # Add to figure's axes list
58
+ if hasattr(editor.fig, "_axes"):
59
+ # Append as a new row
60
+ editor.fig._axes.append([wrapped_ax])
61
+ else:
62
+ # Fallback: raw matplotlib (no recording)
63
+ mpl_ax.imshow(img_array)
64
+ mpl_ax.set_title(filename[:20])
65
+ mpl_ax.axis("off")
66
+
67
+
68
+ def _render_and_update_hitmap(editor):
69
+ """Re-render figure and regenerate hitmap after modification.
70
+
71
+ Returns JSON-serializable response dict with image, bboxes, and size.
72
+ """
73
+ from ._hitmap import generate_hitmap, hitmap_to_base64
74
+
75
+ base64_img, bboxes, img_size = render_with_overrides(
76
+ editor.fig,
77
+ editor.get_effective_style(),
78
+ editor.dark_mode,
79
+ )
80
+
81
+ hitmap_img, color_map = generate_hitmap(editor.fig, dpi=150)
82
+ editor._color_map = color_map
83
+ editor._hitmap_base64 = hitmap_to_base64(hitmap_img)
84
+ editor._hitmap_generated = True
85
+
86
+ return {
87
+ "success": True,
88
+ "image": base64_img,
89
+ "bboxes": bboxes,
90
+ "img_size": {"width": img_size[0], "height": img_size[1]},
91
+ }
92
+
93
+
94
+ def register_image_routes(app, editor):
95
+ """Register image drop routes with the Flask app."""
96
+
97
+ @app.route("/add_image_panel", methods=["POST"])
98
+ def add_image_panel():
99
+ """Add a new panel with an imshow of the dropped image.
100
+
101
+ Expects JSON: {image_data: base64, filename: str, drop_x: float, drop_y: float}
102
+ drop_x, drop_y are normalized (0-1) positions where the image was dropped.
103
+ """
104
+ data = request.get_json() or {}
105
+ image_data = data.get("image_data")
106
+ filename = data.get("filename", "dropped_image")
107
+ drop_x = data.get("drop_x", 0.5)
108
+ drop_y = data.get("drop_y", 0.5)
109
+
110
+ if not image_data:
111
+ return jsonify({"error": "Missing image_data"}), 400
112
+
113
+ try:
114
+ image_bytes = base64.b64decode(image_data)
115
+ img = Image.open(io.BytesIO(image_bytes))
116
+ img_array = np.array(img)
117
+
118
+ _add_image_panel_to_figure(editor, img_array, filename, drop_x, drop_y)
119
+ return jsonify(_render_and_update_hitmap(editor))
120
+
121
+ except Exception as e:
122
+ import traceback
123
+
124
+ traceback.print_exc()
125
+ return jsonify({"error": f"Failed to add image: {str(e)}"}), 500
126
+
127
+ @app.route("/add_image_from_url", methods=["POST"])
128
+ def add_image_from_url():
129
+ """Add a new panel with an imshow from a URL.
130
+
131
+ Expects JSON: {url: str, drop_x: float, drop_y: float}
132
+ """
133
+ data = request.get_json() or {}
134
+ url = data.get("url")
135
+ drop_x = data.get("drop_x", 0.5)
136
+ drop_y = data.get("drop_y", 0.5)
137
+
138
+ if not url:
139
+ return jsonify({"error": "Missing url"}), 400
140
+
141
+ try:
142
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
143
+ with urllib.request.urlopen(req, timeout=10) as response:
144
+ image_bytes = response.read()
145
+
146
+ img = Image.open(io.BytesIO(image_bytes))
147
+ img_array = np.array(img)
148
+ filename = url.split("/")[-1].split("?")[0][:20]
149
+
150
+ _add_image_panel_to_figure(editor, img_array, filename, drop_x, drop_y)
151
+ return jsonify(_render_and_update_hitmap(editor))
152
+
153
+ except Exception as e:
154
+ import traceback
155
+
156
+ traceback.print_exc()
157
+ return jsonify({"error": f"Failed to add image from URL: {str(e)}"}), 500
158
+
159
+ @app.route("/load_recipe", methods=["POST"])
160
+ def load_recipe():
161
+ """Load a recipe file dropped onto the editor.
162
+
163
+ Expects JSON: {recipe_content: str, filename: str}
164
+ """
165
+ data = request.get_json() or {}
166
+ recipe_content = data.get("recipe_content")
167
+ filename = data.get("filename", "recipe.yaml")
168
+
169
+ if not recipe_content:
170
+ return jsonify({"error": "Missing recipe_content"}), 400
171
+
172
+ try:
173
+ import tempfile
174
+
175
+ import figrecipe as fr
176
+
177
+ # Write recipe to temp file
178
+ with tempfile.NamedTemporaryFile(
179
+ mode="w", suffix=".yaml", delete=False
180
+ ) as f:
181
+ f.write(recipe_content)
182
+ temp_path = f.name
183
+
184
+ # Reproduce figure from recipe
185
+ fig, axes = fr.reproduce(temp_path)
186
+
187
+ # Update editor's figure
188
+ editor.fig = fig
189
+ editor._hitmap_generated = False
190
+
191
+ return jsonify({"success": True, "message": f"Loaded {filename}"})
192
+
193
+ except Exception as e:
194
+ import traceback
195
+
196
+ traceback.print_exc()
197
+ return jsonify({"error": f"Failed to load recipe: {str(e)}"}), 500
198
+
199
+
200
+ __all__ = ["register_image_routes"]