scitex 2.4.2__py3-none-any.whl → 2.5.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 (64) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/browser/__init__.py +53 -0
  3. scitex/browser/debugging/__init__.py +56 -0
  4. scitex/browser/debugging/_failure_capture.py +372 -0
  5. scitex/browser/debugging/_sync_session.py +259 -0
  6. scitex/browser/debugging/_test_monitor.py +284 -0
  7. scitex/browser/debugging/_visual_cursor.py +432 -0
  8. scitex/io/_load.py +5 -0
  9. scitex/io/_load_modules/_canvas.py +171 -0
  10. scitex/io/_save.py +8 -0
  11. scitex/io/_save_modules/_canvas.py +356 -0
  12. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +77 -22
  13. scitex/plt/docs/FIGURE_ARCHITECTURE.md +257 -0
  14. scitex/plt/utils/__init__.py +10 -0
  15. scitex/plt/utils/_collect_figure_metadata.py +14 -12
  16. scitex/plt/utils/_csv_column_naming.py +237 -0
  17. scitex/scholar/citation_graph/database.py +9 -2
  18. scitex/scholar/config/ScholarConfig.py +23 -3
  19. scitex/scholar/config/default.yaml +55 -0
  20. scitex/scholar/core/Paper.py +102 -0
  21. scitex/scholar/core/__init__.py +44 -0
  22. scitex/scholar/core/journal_normalizer.py +524 -0
  23. scitex/scholar/core/oa_cache.py +285 -0
  24. scitex/scholar/core/open_access.py +457 -0
  25. scitex/scholar/pdf_download/ScholarPDFDownloader.py +137 -0
  26. scitex/scholar/pdf_download/strategies/__init__.py +6 -0
  27. scitex/scholar/pdf_download/strategies/open_access_download.py +186 -0
  28. scitex/scholar/pipelines/ScholarPipelineSearchParallel.py +18 -3
  29. scitex/scholar/pipelines/ScholarPipelineSearchSingle.py +15 -2
  30. scitex/session/_decorator.py +13 -1
  31. scitex/vis/README.md +246 -615
  32. scitex/vis/__init__.py +138 -78
  33. scitex/vis/canvas.py +423 -0
  34. scitex/vis/docs/CANVAS_ARCHITECTURE.md +307 -0
  35. scitex/vis/editor/__init__.py +1 -1
  36. scitex/vis/editor/_dearpygui_editor.py +1830 -0
  37. scitex/vis/editor/_defaults.py +40 -1
  38. scitex/vis/editor/_edit.py +54 -18
  39. scitex/vis/editor/_flask_editor.py +37 -0
  40. scitex/vis/editor/_qt_editor.py +865 -0
  41. scitex/vis/editor/flask_editor/__init__.py +21 -0
  42. scitex/vis/editor/flask_editor/bbox.py +216 -0
  43. scitex/vis/editor/flask_editor/core.py +152 -0
  44. scitex/vis/editor/flask_editor/plotter.py +130 -0
  45. scitex/vis/editor/flask_editor/renderer.py +184 -0
  46. scitex/vis/editor/flask_editor/templates/__init__.py +33 -0
  47. scitex/vis/editor/flask_editor/templates/html.py +295 -0
  48. scitex/vis/editor/flask_editor/templates/scripts.py +614 -0
  49. scitex/vis/editor/flask_editor/templates/styles.py +549 -0
  50. scitex/vis/editor/flask_editor/utils.py +81 -0
  51. scitex/vis/io/__init__.py +84 -21
  52. scitex/vis/io/canvas.py +226 -0
  53. scitex/vis/io/data.py +204 -0
  54. scitex/vis/io/directory.py +202 -0
  55. scitex/vis/io/export.py +460 -0
  56. scitex/vis/io/panel.py +424 -0
  57. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/METADATA +9 -2
  58. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/RECORD +61 -32
  59. scitex/vis/DJANGO_INTEGRATION.md +0 -677
  60. scitex/vis/editor/_web_editor.py +0 -1440
  61. scitex/vis/tmp.txt +0 -239
  62. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/WHEEL +0 -0
  63. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/entry_points.txt +0 -0
  64. {scitex-2.4.2.dist-info → scitex-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,865 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: ./src/scitex/vis/editor/_qt_editor.py
5
+ """Qt-based figure editor with rich desktop UI."""
6
+
7
+ from pathlib import Path
8
+ from typing import Dict, Any, Optional
9
+ import copy
10
+
11
+
12
+ def _get_qt():
13
+ """Get Qt bindings (PyQt6, PyQt5, PySide6, or PySide2)."""
14
+ try:
15
+ from PyQt6 import QtWidgets, QtCore, QtGui
16
+ return QtWidgets, QtCore, QtGui, "PyQt6"
17
+ except ImportError:
18
+ pass
19
+
20
+ try:
21
+ from PyQt5 import QtWidgets, QtCore, QtGui
22
+ return QtWidgets, QtCore, QtGui, "PyQt5"
23
+ except ImportError:
24
+ pass
25
+
26
+ try:
27
+ from PySide6 import QtWidgets, QtCore, QtGui
28
+ return QtWidgets, QtCore, QtGui, "PySide6"
29
+ except ImportError:
30
+ pass
31
+
32
+ try:
33
+ from PySide2 import QtWidgets, QtCore, QtGui
34
+ return QtWidgets, QtCore, QtGui, "PySide2"
35
+ except ImportError:
36
+ pass
37
+
38
+ raise ImportError(
39
+ "Qt backend requires PyQt6, PyQt5, PySide6, or PySide2. "
40
+ "Install with: pip install PyQt6"
41
+ )
42
+
43
+
44
+ class QtEditor:
45
+ """
46
+ Rich desktop figure editor using Qt (PyQt6/5 or PySide6/2).
47
+
48
+ Features:
49
+ - Native desktop UI with dockable panels
50
+ - Embedded matplotlib canvas with navigation
51
+ - Property editors with spinboxes, sliders, color pickers
52
+ - Real-time preview updates
53
+ - Save to .manual.json
54
+ - SciTeX style defaults pre-filled
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ json_path: Path,
60
+ metadata: Dict[str, Any],
61
+ csv_data: Optional[Any] = None,
62
+ png_path: Optional[Path] = None,
63
+ manual_overrides: Optional[Dict[str, Any]] = None,
64
+ ):
65
+ self.json_path = Path(json_path)
66
+ self.metadata = metadata
67
+ self.csv_data = csv_data
68
+ self.png_path = Path(png_path) if png_path else None
69
+ self.manual_overrides = manual_overrides or {}
70
+
71
+ # Get SciTeX defaults and merge with metadata
72
+ from ._defaults import get_scitex_defaults, extract_defaults_from_metadata
73
+ self.scitex_defaults = get_scitex_defaults()
74
+ self.metadata_defaults = extract_defaults_from_metadata(metadata)
75
+
76
+ # Start with defaults, then overlay manual overrides
77
+ self.current_overrides = copy.deepcopy(self.scitex_defaults)
78
+ self.current_overrides.update(self.metadata_defaults)
79
+ self.current_overrides.update(self.manual_overrides)
80
+
81
+ # Track modifications
82
+ self._initial_overrides = copy.deepcopy(self.current_overrides)
83
+ self._user_modified = False
84
+
85
+ # Qt components (initialized in run())
86
+ self.app = None
87
+ self.main_window = None
88
+ self.canvas = None
89
+ self.fig = None
90
+ self.ax = None
91
+
92
+ def run(self):
93
+ """Launch the Qt editor."""
94
+ QtWidgets, QtCore, QtGui, qt_version = _get_qt()
95
+
96
+ # Create application
97
+ self.app = QtWidgets.QApplication.instance()
98
+ if self.app is None:
99
+ self.app = QtWidgets.QApplication([])
100
+
101
+ # Create main window
102
+ self.main_window = QtEditorWindow(
103
+ self, QtWidgets, QtCore, QtGui, qt_version
104
+ )
105
+ self.main_window.show()
106
+
107
+ # Start event loop
108
+ self.app.exec() if hasattr(self.app, 'exec') else self.app.exec_()
109
+
110
+
111
+ class QtEditorWindow:
112
+ """Qt main window for the editor."""
113
+
114
+ def __init__(self, editor, QtWidgets, QtCore, QtGui, qt_version):
115
+ self.editor = editor
116
+ self.QtWidgets = QtWidgets
117
+ self.QtCore = QtCore
118
+ self.QtGui = QtGui
119
+ self.qt_version = qt_version
120
+
121
+ # Create main window
122
+ self.window = QtWidgets.QMainWindow()
123
+ self.window.setWindowTitle(f"SciTeX Editor - {editor.json_path.name}")
124
+ self.window.resize(1400, 900)
125
+
126
+ # Central widget with splitter
127
+ central_widget = QtWidgets.QWidget()
128
+ self.window.setCentralWidget(central_widget)
129
+
130
+ layout = QtWidgets.QHBoxLayout(central_widget)
131
+
132
+ # Splitter for resizable panels
133
+ splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal
134
+ if hasattr(QtCore.Qt, 'Orientation')
135
+ else QtCore.Qt.Horizontal)
136
+ layout.addWidget(splitter)
137
+
138
+ # Left panel: Canvas
139
+ self._create_canvas_panel(splitter)
140
+
141
+ # Right panel: Controls
142
+ self._create_control_panel(splitter)
143
+
144
+ # Set splitter sizes
145
+ splitter.setSizes([900, 400])
146
+
147
+ # Create toolbar
148
+ self._create_toolbar()
149
+
150
+ # Create status bar
151
+ self.status_bar = self.window.statusBar()
152
+ self.status_bar.showMessage("Ready")
153
+
154
+ # Initial render
155
+ self._render_figure()
156
+
157
+ def show(self):
158
+ """Show the window."""
159
+ self.window.show()
160
+
161
+ def _create_canvas_panel(self, parent):
162
+ """Create the matplotlib canvas panel."""
163
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
164
+ from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
165
+ from matplotlib.figure import Figure
166
+
167
+ canvas_widget = self.QtWidgets.QWidget()
168
+ canvas_layout = self.QtWidgets.QVBoxLayout(canvas_widget)
169
+
170
+ # Create matplotlib figure
171
+ self.fig = Figure(figsize=(8, 6), dpi=100)
172
+ self.ax = self.fig.add_subplot(111)
173
+
174
+ # Create canvas
175
+ self.canvas = FigureCanvasQTAgg(self.fig)
176
+ canvas_layout.addWidget(self.canvas)
177
+
178
+ # Navigation toolbar
179
+ toolbar = NavigationToolbar2QT(self.canvas, canvas_widget)
180
+ canvas_layout.addWidget(toolbar)
181
+
182
+ parent.addWidget(canvas_widget)
183
+
184
+ def _create_control_panel(self, parent):
185
+ """Create the control panel with property editors."""
186
+ scroll_area = self.QtWidgets.QScrollArea()
187
+ scroll_area.setWidgetResizable(True)
188
+ scroll_area.setMinimumWidth(350)
189
+
190
+ control_widget = self.QtWidgets.QWidget()
191
+ control_layout = self.QtWidgets.QVBoxLayout(control_widget)
192
+ control_layout.setSpacing(10)
193
+
194
+ # Labels Section
195
+ self._create_labels_section(control_layout)
196
+
197
+ # Axis Limits Section
198
+ self._create_limits_section(control_layout)
199
+
200
+ # Line Style Section
201
+ self._create_line_style_section(control_layout)
202
+
203
+ # Font Settings Section
204
+ self._create_font_section(control_layout)
205
+
206
+ # Tick Settings Section
207
+ self._create_tick_section(control_layout)
208
+
209
+ # Style Section
210
+ self._create_style_section(control_layout)
211
+
212
+ # Legend Section
213
+ self._create_legend_section(control_layout)
214
+
215
+ # Dimensions Section
216
+ self._create_dimensions_section(control_layout)
217
+
218
+ # Annotations Section
219
+ self._create_annotations_section(control_layout)
220
+
221
+ # Add stretch at bottom
222
+ control_layout.addStretch()
223
+
224
+ scroll_area.setWidget(control_widget)
225
+ parent.addWidget(scroll_area)
226
+
227
+ def _create_group_box(self, title: str) -> "QtWidgets.QGroupBox":
228
+ """Create a collapsible group box."""
229
+ group = self.QtWidgets.QGroupBox(title)
230
+ group.setCheckable(True)
231
+ group.setChecked(True)
232
+ return group
233
+
234
+ def _create_labels_section(self, parent_layout):
235
+ """Create labels section."""
236
+ group = self._create_group_box("Labels")
237
+ layout = self.QtWidgets.QFormLayout(group)
238
+
239
+ self.title_edit = self.QtWidgets.QLineEdit(
240
+ self.editor.current_overrides.get('title', '')
241
+ )
242
+ self.title_edit.editingFinished.connect(self._on_value_change)
243
+ layout.addRow("Title:", self.title_edit)
244
+
245
+ self.xlabel_edit = self.QtWidgets.QLineEdit(
246
+ self.editor.current_overrides.get('xlabel', '')
247
+ )
248
+ self.xlabel_edit.editingFinished.connect(self._on_value_change)
249
+ layout.addRow("X Label:", self.xlabel_edit)
250
+
251
+ self.ylabel_edit = self.QtWidgets.QLineEdit(
252
+ self.editor.current_overrides.get('ylabel', '')
253
+ )
254
+ self.ylabel_edit.editingFinished.connect(self._on_value_change)
255
+ layout.addRow("Y Label:", self.ylabel_edit)
256
+
257
+ parent_layout.addWidget(group)
258
+
259
+ def _create_limits_section(self, parent_layout):
260
+ """Create axis limits section."""
261
+ group = self._create_group_box("Axis Limits")
262
+ layout = self.QtWidgets.QGridLayout(group)
263
+
264
+ xlim = self.editor.current_overrides.get('xlim', [0, 1])
265
+ ylim = self.editor.current_overrides.get('ylim', [0, 1])
266
+
267
+ layout.addWidget(self.QtWidgets.QLabel("X Min:"), 0, 0)
268
+ self.xmin_spin = self.QtWidgets.QDoubleSpinBox()
269
+ self.xmin_spin.setRange(-1e9, 1e9)
270
+ self.xmin_spin.setDecimals(4)
271
+ self.xmin_spin.setValue(xlim[0] if xlim else 0)
272
+ layout.addWidget(self.xmin_spin, 0, 1)
273
+
274
+ layout.addWidget(self.QtWidgets.QLabel("X Max:"), 0, 2)
275
+ self.xmax_spin = self.QtWidgets.QDoubleSpinBox()
276
+ self.xmax_spin.setRange(-1e9, 1e9)
277
+ self.xmax_spin.setDecimals(4)
278
+ self.xmax_spin.setValue(xlim[1] if xlim else 1)
279
+ layout.addWidget(self.xmax_spin, 0, 3)
280
+
281
+ layout.addWidget(self.QtWidgets.QLabel("Y Min:"), 1, 0)
282
+ self.ymin_spin = self.QtWidgets.QDoubleSpinBox()
283
+ self.ymin_spin.setRange(-1e9, 1e9)
284
+ self.ymin_spin.setDecimals(4)
285
+ self.ymin_spin.setValue(ylim[0] if ylim else 0)
286
+ layout.addWidget(self.ymin_spin, 1, 1)
287
+
288
+ layout.addWidget(self.QtWidgets.QLabel("Y Max:"), 1, 2)
289
+ self.ymax_spin = self.QtWidgets.QDoubleSpinBox()
290
+ self.ymax_spin.setRange(-1e9, 1e9)
291
+ self.ymax_spin.setDecimals(4)
292
+ self.ymax_spin.setValue(ylim[1] if ylim else 1)
293
+ layout.addWidget(self.ymax_spin, 1, 3)
294
+
295
+ apply_btn = self.QtWidgets.QPushButton("Apply Limits")
296
+ apply_btn.clicked.connect(self._apply_limits)
297
+ layout.addWidget(apply_btn, 2, 0, 1, 4)
298
+
299
+ parent_layout.addWidget(group)
300
+
301
+ def _create_line_style_section(self, parent_layout):
302
+ """Create line style section."""
303
+ group = self._create_group_box("Line Style")
304
+ layout = self.QtWidgets.QFormLayout(group)
305
+
306
+ self.linewidth_spin = self.QtWidgets.QDoubleSpinBox()
307
+ self.linewidth_spin.setRange(0.1, 10.0)
308
+ self.linewidth_spin.setSingleStep(0.1)
309
+ self.linewidth_spin.setValue(self.editor.current_overrides.get('linewidth', 1.0))
310
+ self.linewidth_spin.valueChanged.connect(self._on_value_change)
311
+ layout.addRow("Line Width (pt):", self.linewidth_spin)
312
+
313
+ parent_layout.addWidget(group)
314
+
315
+ def _create_font_section(self, parent_layout):
316
+ """Create font settings section."""
317
+ group = self._create_group_box("Font Settings")
318
+ layout = self.QtWidgets.QFormLayout(group)
319
+
320
+ self.title_fontsize_spin = self.QtWidgets.QSpinBox()
321
+ self.title_fontsize_spin.setRange(6, 24)
322
+ self.title_fontsize_spin.setValue(self.editor.current_overrides.get('title_fontsize', 8))
323
+ self.title_fontsize_spin.valueChanged.connect(self._on_value_change)
324
+ layout.addRow("Title Font Size:", self.title_fontsize_spin)
325
+
326
+ self.axis_fontsize_spin = self.QtWidgets.QSpinBox()
327
+ self.axis_fontsize_spin.setRange(6, 24)
328
+ self.axis_fontsize_spin.setValue(self.editor.current_overrides.get('axis_fontsize', 7))
329
+ self.axis_fontsize_spin.valueChanged.connect(self._on_value_change)
330
+ layout.addRow("Axis Font Size:", self.axis_fontsize_spin)
331
+
332
+ self.tick_fontsize_spin = self.QtWidgets.QSpinBox()
333
+ self.tick_fontsize_spin.setRange(6, 24)
334
+ self.tick_fontsize_spin.setValue(self.editor.current_overrides.get('tick_fontsize', 7))
335
+ self.tick_fontsize_spin.valueChanged.connect(self._on_value_change)
336
+ layout.addRow("Tick Font Size:", self.tick_fontsize_spin)
337
+
338
+ self.legend_fontsize_spin = self.QtWidgets.QSpinBox()
339
+ self.legend_fontsize_spin.setRange(4, 20)
340
+ self.legend_fontsize_spin.setValue(self.editor.current_overrides.get('legend_fontsize', 6))
341
+ self.legend_fontsize_spin.valueChanged.connect(self._on_value_change)
342
+ layout.addRow("Legend Font Size:", self.legend_fontsize_spin)
343
+
344
+ parent_layout.addWidget(group)
345
+
346
+ def _create_tick_section(self, parent_layout):
347
+ """Create tick settings section."""
348
+ group = self._create_group_box("Tick Settings")
349
+ layout = self.QtWidgets.QFormLayout(group)
350
+
351
+ self.n_ticks_spin = self.QtWidgets.QSpinBox()
352
+ self.n_ticks_spin.setRange(2, 15)
353
+ self.n_ticks_spin.setValue(self.editor.current_overrides.get('n_ticks', 4))
354
+ self.n_ticks_spin.valueChanged.connect(self._on_value_change)
355
+ layout.addRow("N Ticks:", self.n_ticks_spin)
356
+
357
+ self.tick_length_spin = self.QtWidgets.QDoubleSpinBox()
358
+ self.tick_length_spin.setRange(0.1, 5.0)
359
+ self.tick_length_spin.setSingleStep(0.1)
360
+ self.tick_length_spin.setValue(self.editor.current_overrides.get('tick_length', 0.8))
361
+ self.tick_length_spin.valueChanged.connect(self._on_value_change)
362
+ layout.addRow("Tick Length (mm):", self.tick_length_spin)
363
+
364
+ self.tick_width_spin = self.QtWidgets.QDoubleSpinBox()
365
+ self.tick_width_spin.setRange(0.05, 2.0)
366
+ self.tick_width_spin.setSingleStep(0.05)
367
+ self.tick_width_spin.setValue(self.editor.current_overrides.get('tick_width', 0.2))
368
+ self.tick_width_spin.valueChanged.connect(self._on_value_change)
369
+ layout.addRow("Tick Width (mm):", self.tick_width_spin)
370
+
371
+ self.tick_direction_combo = self.QtWidgets.QComboBox()
372
+ self.tick_direction_combo.addItems(["out", "in", "inout"])
373
+ self.tick_direction_combo.setCurrentText(
374
+ self.editor.current_overrides.get('tick_direction', 'out')
375
+ )
376
+ self.tick_direction_combo.currentTextChanged.connect(self._on_value_change)
377
+ layout.addRow("Tick Direction:", self.tick_direction_combo)
378
+
379
+ parent_layout.addWidget(group)
380
+
381
+ def _create_style_section(self, parent_layout):
382
+ """Create style section."""
383
+ group = self._create_group_box("Style")
384
+ layout = self.QtWidgets.QFormLayout(group)
385
+
386
+ self.grid_check = self.QtWidgets.QCheckBox()
387
+ self.grid_check.setChecked(self.editor.current_overrides.get('grid', False))
388
+ self.grid_check.stateChanged.connect(self._on_value_change)
389
+ layout.addRow("Show Grid:", self.grid_check)
390
+
391
+ self.hide_top_spine_check = self.QtWidgets.QCheckBox()
392
+ self.hide_top_spine_check.setChecked(
393
+ self.editor.current_overrides.get('hide_top_spine', True)
394
+ )
395
+ self.hide_top_spine_check.stateChanged.connect(self._on_value_change)
396
+ layout.addRow("Hide Top Spine:", self.hide_top_spine_check)
397
+
398
+ self.hide_right_spine_check = self.QtWidgets.QCheckBox()
399
+ self.hide_right_spine_check.setChecked(
400
+ self.editor.current_overrides.get('hide_right_spine', True)
401
+ )
402
+ self.hide_right_spine_check.stateChanged.connect(self._on_value_change)
403
+ layout.addRow("Hide Right Spine:", self.hide_right_spine_check)
404
+
405
+ self.transparent_check = self.QtWidgets.QCheckBox()
406
+ self.transparent_check.setChecked(
407
+ self.editor.current_overrides.get('transparent', True)
408
+ )
409
+ self.transparent_check.stateChanged.connect(self._on_value_change)
410
+ layout.addRow("Transparent BG:", self.transparent_check)
411
+
412
+ self.axis_width_spin = self.QtWidgets.QDoubleSpinBox()
413
+ self.axis_width_spin.setRange(0.05, 2.0)
414
+ self.axis_width_spin.setSingleStep(0.05)
415
+ self.axis_width_spin.setValue(self.editor.current_overrides.get('axis_width', 0.2))
416
+ self.axis_width_spin.valueChanged.connect(self._on_value_change)
417
+ layout.addRow("Axis Width (mm):", self.axis_width_spin)
418
+
419
+ # Background color button
420
+ self.bg_color = self.editor.current_overrides.get('facecolor', '#ffffff')
421
+ self.bg_color_btn = self.QtWidgets.QPushButton("Choose...")
422
+ self.bg_color_btn.clicked.connect(self._choose_bg_color)
423
+ layout.addRow("Background Color:", self.bg_color_btn)
424
+
425
+ parent_layout.addWidget(group)
426
+
427
+ def _create_legend_section(self, parent_layout):
428
+ """Create legend section."""
429
+ group = self._create_group_box("Legend")
430
+ layout = self.QtWidgets.QFormLayout(group)
431
+
432
+ self.legend_visible_check = self.QtWidgets.QCheckBox()
433
+ self.legend_visible_check.setChecked(
434
+ self.editor.current_overrides.get('legend_visible', True)
435
+ )
436
+ self.legend_visible_check.stateChanged.connect(self._on_value_change)
437
+ layout.addRow("Show Legend:", self.legend_visible_check)
438
+
439
+ self.legend_frameon_check = self.QtWidgets.QCheckBox()
440
+ self.legend_frameon_check.setChecked(
441
+ self.editor.current_overrides.get('legend_frameon', False)
442
+ )
443
+ self.legend_frameon_check.stateChanged.connect(self._on_value_change)
444
+ layout.addRow("Show Frame:", self.legend_frameon_check)
445
+
446
+ self.legend_loc_combo = self.QtWidgets.QComboBox()
447
+ self.legend_loc_combo.addItems([
448
+ "best", "upper right", "upper left", "lower right",
449
+ "lower left", "center right", "center left",
450
+ "upper center", "lower center", "center"
451
+ ])
452
+ self.legend_loc_combo.setCurrentText(
453
+ self.editor.current_overrides.get('legend_loc', 'best')
454
+ )
455
+ self.legend_loc_combo.currentTextChanged.connect(self._on_value_change)
456
+ layout.addRow("Position:", self.legend_loc_combo)
457
+
458
+ parent_layout.addWidget(group)
459
+
460
+ def _create_dimensions_section(self, parent_layout):
461
+ """Create dimensions section."""
462
+ group = self._create_group_box("Dimensions")
463
+ layout = self.QtWidgets.QFormLayout(group)
464
+
465
+ fig_size = self.editor.current_overrides.get('fig_size', [3.15, 2.68])
466
+
467
+ self.fig_width_spin = self.QtWidgets.QDoubleSpinBox()
468
+ self.fig_width_spin.setRange(1.0, 20.0)
469
+ self.fig_width_spin.setSingleStep(0.1)
470
+ self.fig_width_spin.setValue(fig_size[0])
471
+ layout.addRow("Width (in):", self.fig_width_spin)
472
+
473
+ self.fig_height_spin = self.QtWidgets.QDoubleSpinBox()
474
+ self.fig_height_spin.setRange(1.0, 20.0)
475
+ self.fig_height_spin.setSingleStep(0.1)
476
+ self.fig_height_spin.setValue(fig_size[1])
477
+ layout.addRow("Height (in):", self.fig_height_spin)
478
+
479
+ self.dpi_spin = self.QtWidgets.QSpinBox()
480
+ self.dpi_spin.setRange(72, 600)
481
+ self.dpi_spin.setValue(self.editor.current_overrides.get('dpi', 300))
482
+ self.dpi_spin.valueChanged.connect(self._on_value_change)
483
+ layout.addRow("DPI:", self.dpi_spin)
484
+
485
+ parent_layout.addWidget(group)
486
+
487
+ def _create_annotations_section(self, parent_layout):
488
+ """Create annotations section."""
489
+ group = self._create_group_box("Annotations")
490
+ layout = self.QtWidgets.QVBoxLayout(group)
491
+
492
+ # Input fields
493
+ form_layout = self.QtWidgets.QFormLayout()
494
+
495
+ self.annot_text_edit = self.QtWidgets.QLineEdit()
496
+ form_layout.addRow("Text:", self.annot_text_edit)
497
+
498
+ pos_layout = self.QtWidgets.QHBoxLayout()
499
+ self.annot_x_spin = self.QtWidgets.QDoubleSpinBox()
500
+ self.annot_x_spin.setRange(0, 1)
501
+ self.annot_x_spin.setSingleStep(0.05)
502
+ self.annot_x_spin.setValue(0.5)
503
+ pos_layout.addWidget(self.QtWidgets.QLabel("X:"))
504
+ pos_layout.addWidget(self.annot_x_spin)
505
+
506
+ self.annot_y_spin = self.QtWidgets.QDoubleSpinBox()
507
+ self.annot_y_spin.setRange(0, 1)
508
+ self.annot_y_spin.setSingleStep(0.05)
509
+ self.annot_y_spin.setValue(0.5)
510
+ pos_layout.addWidget(self.QtWidgets.QLabel("Y:"))
511
+ pos_layout.addWidget(self.annot_y_spin)
512
+
513
+ form_layout.addRow("Position:", pos_layout)
514
+ layout.addLayout(form_layout)
515
+
516
+ add_btn = self.QtWidgets.QPushButton("Add Annotation")
517
+ add_btn.clicked.connect(self._add_annotation)
518
+ layout.addWidget(add_btn)
519
+
520
+ # Annotation list
521
+ self.annot_list = self.QtWidgets.QListWidget()
522
+ self.annot_list.setMaximumHeight(100)
523
+ layout.addWidget(self.annot_list)
524
+
525
+ remove_btn = self.QtWidgets.QPushButton("Remove Selected")
526
+ remove_btn.clicked.connect(self._remove_annotation)
527
+ layout.addWidget(remove_btn)
528
+
529
+ self._update_annotations_list()
530
+
531
+ parent_layout.addWidget(group)
532
+
533
+ def _create_toolbar(self):
534
+ """Create the main toolbar."""
535
+ toolbar = self.window.addToolBar("Main")
536
+
537
+ # Update action
538
+ update_action = toolbar.addAction("Update Preview")
539
+ update_action.triggered.connect(self._render_figure)
540
+
541
+ # Save action
542
+ save_action = toolbar.addAction("Save")
543
+ save_action.triggered.connect(self._save_manual)
544
+
545
+ # Reset action
546
+ reset_action = toolbar.addAction("Reset")
547
+ reset_action.triggered.connect(self._reset_overrides)
548
+
549
+ toolbar.addSeparator()
550
+
551
+ # Export action
552
+ export_action = toolbar.addAction("Export PNG")
553
+ export_action.triggered.connect(self._export_png)
554
+
555
+ def _on_value_change(self, value=None):
556
+ """Handle value changes from widgets."""
557
+ self.editor._user_modified = True
558
+ self._collect_overrides()
559
+ self._render_figure()
560
+
561
+ def _collect_overrides(self):
562
+ """Collect current values from all widgets."""
563
+ o = self.editor.current_overrides
564
+
565
+ # Labels
566
+ o['title'] = self.title_edit.text()
567
+ o['xlabel'] = self.xlabel_edit.text()
568
+ o['ylabel'] = self.ylabel_edit.text()
569
+
570
+ # Line style
571
+ o['linewidth'] = self.linewidth_spin.value()
572
+
573
+ # Font settings
574
+ o['title_fontsize'] = self.title_fontsize_spin.value()
575
+ o['axis_fontsize'] = self.axis_fontsize_spin.value()
576
+ o['tick_fontsize'] = self.tick_fontsize_spin.value()
577
+ o['legend_fontsize'] = self.legend_fontsize_spin.value()
578
+
579
+ # Tick settings
580
+ o['n_ticks'] = self.n_ticks_spin.value()
581
+ o['tick_length'] = self.tick_length_spin.value()
582
+ o['tick_width'] = self.tick_width_spin.value()
583
+ o['tick_direction'] = self.tick_direction_combo.currentText()
584
+
585
+ # Style
586
+ o['grid'] = self.grid_check.isChecked()
587
+ o['hide_top_spine'] = self.hide_top_spine_check.isChecked()
588
+ o['hide_right_spine'] = self.hide_right_spine_check.isChecked()
589
+ o['transparent'] = self.transparent_check.isChecked()
590
+ o['axis_width'] = self.axis_width_spin.value()
591
+ o['facecolor'] = self.bg_color
592
+
593
+ # Legend
594
+ o['legend_visible'] = self.legend_visible_check.isChecked()
595
+ o['legend_frameon'] = self.legend_frameon_check.isChecked()
596
+ o['legend_loc'] = self.legend_loc_combo.currentText()
597
+
598
+ # Dimensions
599
+ o['fig_size'] = [self.fig_width_spin.value(), self.fig_height_spin.value()]
600
+ o['dpi'] = self.dpi_spin.value()
601
+
602
+ def _apply_limits(self):
603
+ """Apply axis limits."""
604
+ xmin = self.xmin_spin.value()
605
+ xmax = self.xmax_spin.value()
606
+ ymin = self.ymin_spin.value()
607
+ ymax = self.ymax_spin.value()
608
+
609
+ if xmin < xmax:
610
+ self.editor.current_overrides['xlim'] = [xmin, xmax]
611
+ if ymin < ymax:
612
+ self.editor.current_overrides['ylim'] = [ymin, ymax]
613
+
614
+ self.editor._user_modified = True
615
+ self._render_figure()
616
+
617
+ def _choose_bg_color(self):
618
+ """Open color dialog for background."""
619
+ color = self.QtWidgets.QColorDialog.getColor()
620
+ if color.isValid():
621
+ self.bg_color = color.name()
622
+ self.editor.current_overrides['facecolor'] = self.bg_color
623
+ self._render_figure()
624
+
625
+ def _add_annotation(self):
626
+ """Add text annotation."""
627
+ text = self.annot_text_edit.text()
628
+ if not text:
629
+ return
630
+
631
+ x = self.annot_x_spin.value()
632
+ y = self.annot_y_spin.value()
633
+
634
+ if 'annotations' not in self.editor.current_overrides:
635
+ self.editor.current_overrides['annotations'] = []
636
+
637
+ self.editor.current_overrides['annotations'].append({
638
+ 'type': 'text',
639
+ 'text': text,
640
+ 'x': x,
641
+ 'y': y,
642
+ 'fontsize': self.editor.current_overrides.get('axis_fontsize', 7),
643
+ })
644
+
645
+ self.annot_text_edit.clear()
646
+ self._update_annotations_list()
647
+ self.editor._user_modified = True
648
+ self._render_figure()
649
+
650
+ def _remove_annotation(self):
651
+ """Remove selected annotation."""
652
+ row = self.annot_list.currentRow()
653
+ annotations = self.editor.current_overrides.get('annotations', [])
654
+
655
+ if row >= 0 and row < len(annotations):
656
+ del annotations[row]
657
+ self._update_annotations_list()
658
+ self.editor._user_modified = True
659
+ self._render_figure()
660
+
661
+ def _update_annotations_list(self):
662
+ """Update the annotations list widget."""
663
+ self.annot_list.clear()
664
+ for ann in self.editor.current_overrides.get('annotations', []):
665
+ if ann.get('type') == 'text':
666
+ text = ann.get('text', '')[:20]
667
+ x = ann.get('x', 0)
668
+ y = ann.get('y', 0)
669
+ self.annot_list.addItem(f"{text} ({x:.2f}, {y:.2f})")
670
+
671
+ def _render_figure(self):
672
+ """Render the figure with current overrides."""
673
+ from matplotlib.ticker import MaxNLocator
674
+
675
+ self.ax.clear()
676
+ o = self.editor.current_overrides
677
+ mm_to_pt = 2.83465
678
+
679
+ # Background
680
+ if o.get('transparent', True):
681
+ self.fig.patch.set_facecolor('none')
682
+ self.ax.patch.set_facecolor('none')
683
+ else:
684
+ self.fig.patch.set_facecolor(o.get('facecolor', '#ffffff'))
685
+ self.ax.patch.set_facecolor(o.get('facecolor', '#ffffff'))
686
+
687
+ # Plot from CSV
688
+ if self.editor.csv_data is not None:
689
+ self._plot_from_csv(o)
690
+ else:
691
+ self.ax.text(0.5, 0.5, "No plot data available\n(CSV not found)",
692
+ ha='center', va='center', transform=self.ax.transAxes)
693
+
694
+ # Labels
695
+ if o.get('title'):
696
+ self.ax.set_title(o['title'], fontsize=o.get('title_fontsize', 8))
697
+ if o.get('xlabel'):
698
+ self.ax.set_xlabel(o['xlabel'], fontsize=o.get('axis_fontsize', 7))
699
+ if o.get('ylabel'):
700
+ self.ax.set_ylabel(o['ylabel'], fontsize=o.get('axis_fontsize', 7))
701
+
702
+ # Ticks
703
+ self.ax.tick_params(
704
+ axis='both',
705
+ labelsize=o.get('tick_fontsize', 7),
706
+ length=o.get('tick_length', 0.8) * mm_to_pt,
707
+ width=o.get('tick_width', 0.2) * mm_to_pt,
708
+ direction=o.get('tick_direction', 'out'),
709
+ )
710
+
711
+ self.ax.xaxis.set_major_locator(MaxNLocator(nbins=o.get('n_ticks', 4)))
712
+ self.ax.yaxis.set_major_locator(MaxNLocator(nbins=o.get('n_ticks', 4)))
713
+
714
+ # Grid
715
+ if o.get('grid'):
716
+ self.ax.grid(True, linewidth=o.get('axis_width', 0.2) * mm_to_pt, alpha=0.3)
717
+
718
+ # Limits
719
+ if o.get('xlim'):
720
+ self.ax.set_xlim(o['xlim'])
721
+ if o.get('ylim'):
722
+ self.ax.set_ylim(o['ylim'])
723
+
724
+ # Spines
725
+ if o.get('hide_top_spine', True):
726
+ self.ax.spines['top'].set_visible(False)
727
+ if o.get('hide_right_spine', True):
728
+ self.ax.spines['right'].set_visible(False)
729
+
730
+ for spine in self.ax.spines.values():
731
+ spine.set_linewidth(o.get('axis_width', 0.2) * mm_to_pt)
732
+
733
+ # Annotations
734
+ for ann in o.get('annotations', []):
735
+ if ann.get('type') == 'text':
736
+ self.ax.text(
737
+ ann.get('x', 0.5),
738
+ ann.get('y', 0.5),
739
+ ann.get('text', ''),
740
+ transform=self.ax.transAxes,
741
+ fontsize=ann.get('fontsize', o.get('axis_fontsize', 7)),
742
+ )
743
+
744
+ self.fig.tight_layout()
745
+ self.canvas.draw()
746
+ self.status_bar.showMessage("Preview updated")
747
+
748
+ def _plot_from_csv(self, o):
749
+ """Reconstruct plot from CSV data."""
750
+ import pandas as pd
751
+
752
+ if not isinstance(self.editor.csv_data, pd.DataFrame):
753
+ return
754
+
755
+ df = self.editor.csv_data
756
+ linewidth = o.get('linewidth', 1.0)
757
+ legend_visible = o.get('legend_visible', True)
758
+ legend_fontsize = o.get('legend_fontsize', 6)
759
+ legend_frameon = o.get('legend_frameon', False)
760
+ legend_loc = o.get('legend_loc', 'best')
761
+
762
+ traces = o.get('traces', [])
763
+
764
+ if traces:
765
+ for trace in traces:
766
+ csv_cols = trace.get('csv_columns', {})
767
+ x_col = csv_cols.get('x')
768
+ y_col = csv_cols.get('y')
769
+
770
+ if x_col in df.columns and y_col in df.columns:
771
+ self.ax.plot(
772
+ df[x_col],
773
+ df[y_col],
774
+ label=trace.get('label', trace.get('id', '')),
775
+ color=trace.get('color'),
776
+ linestyle=trace.get('linestyle', '-'),
777
+ linewidth=trace.get('linewidth', linewidth),
778
+ )
779
+
780
+ if legend_visible and any(t.get('label') for t in traces):
781
+ self.ax.legend(fontsize=legend_fontsize, frameon=legend_frameon, loc=legend_loc)
782
+ else:
783
+ cols = df.columns.tolist()
784
+ if len(cols) >= 2:
785
+ x_col = cols[0]
786
+ for y_col in cols[1:]:
787
+ try:
788
+ self.ax.plot(df[x_col], df[y_col], label=str(y_col), linewidth=linewidth)
789
+ except Exception:
790
+ pass
791
+ if len(cols) > 2 and legend_visible:
792
+ self.ax.legend(fontsize=legend_fontsize, frameon=legend_frameon, loc=legend_loc)
793
+
794
+ def _save_manual(self):
795
+ """Save to .manual.json."""
796
+ from ._edit import save_manual_overrides
797
+
798
+ try:
799
+ self._collect_overrides()
800
+ manual_path = save_manual_overrides(
801
+ self.editor.json_path,
802
+ self.editor.current_overrides
803
+ )
804
+ self.status_bar.showMessage(f"Saved: {manual_path.name}")
805
+ self.QtWidgets.QMessageBox.information(
806
+ self.window, "Saved",
807
+ f"Manual overrides saved to:\n{manual_path}"
808
+ )
809
+ except Exception as e:
810
+ self.QtWidgets.QMessageBox.critical(
811
+ self.window, "Error",
812
+ f"Failed to save: {e}"
813
+ )
814
+
815
+ def _reset_overrides(self):
816
+ """Reset to initial overrides."""
817
+ reply = self.QtWidgets.QMessageBox.question(
818
+ self.window, "Reset",
819
+ "Reset all changes to original values?",
820
+ self.QtWidgets.QMessageBox.StandardButton.Yes |
821
+ self.QtWidgets.QMessageBox.StandardButton.No
822
+ if hasattr(self.QtWidgets.QMessageBox, 'StandardButton')
823
+ else self.QtWidgets.QMessageBox.Yes | self.QtWidgets.QMessageBox.No
824
+ )
825
+
826
+ yes_val = (self.QtWidgets.QMessageBox.StandardButton.Yes
827
+ if hasattr(self.QtWidgets.QMessageBox, 'StandardButton')
828
+ else self.QtWidgets.QMessageBox.Yes)
829
+
830
+ if reply == yes_val:
831
+ self.editor.current_overrides = copy.deepcopy(self.editor._initial_overrides)
832
+ self.editor._user_modified = False
833
+
834
+ # Update UI
835
+ self.title_edit.setText(self.editor.current_overrides.get('title', ''))
836
+ self.xlabel_edit.setText(self.editor.current_overrides.get('xlabel', ''))
837
+ self.ylabel_edit.setText(self.editor.current_overrides.get('ylabel', ''))
838
+ self.linewidth_spin.setValue(self.editor.current_overrides.get('linewidth', 1.0))
839
+ self.grid_check.setChecked(self.editor.current_overrides.get('grid', False))
840
+
841
+ self._update_annotations_list()
842
+ self._render_figure()
843
+ self.status_bar.showMessage("Reset to original")
844
+
845
+ def _export_png(self):
846
+ """Export current view to PNG."""
847
+ filepath, _ = self.QtWidgets.QFileDialog.getSaveFileName(
848
+ self.window, "Export PNG",
849
+ str(self.editor.json_path.with_suffix('.edited.png')),
850
+ "PNG files (*.png);;All files (*)"
851
+ )
852
+
853
+ if filepath:
854
+ self._collect_overrides()
855
+ o = self.editor.current_overrides
856
+ dpi = o.get('dpi', 300)
857
+
858
+ self.fig.savefig(
859
+ filepath, dpi=dpi, bbox_inches='tight',
860
+ transparent=o.get('transparent', True)
861
+ )
862
+ self.status_bar.showMessage(f"Exported: {Path(filepath).name}")
863
+
864
+
865
+ # EOF