datalab-platform 1.0.1__py3-none-any.whl → 1.0.3__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 (50) hide show
  1. datalab/__init__.py +1 -1
  2. datalab/adapters_metadata/common.py +2 -2
  3. datalab/adapters_plotpy/converters.py +3 -1
  4. datalab/adapters_plotpy/coordutils.py +157 -0
  5. datalab/adapters_plotpy/roi/image.py +35 -6
  6. datalab/adapters_plotpy/roi/signal.py +8 -1
  7. datalab/config.py +88 -26
  8. datalab/control/baseproxy.py +70 -0
  9. datalab/control/proxy.py +33 -0
  10. datalab/control/remote.py +35 -0
  11. datalab/data/doc/DataLab_en.pdf +0 -0
  12. datalab/data/doc/DataLab_fr.pdf +0 -0
  13. datalab/data/icons/create/linear_chirp.svg +1 -1
  14. datalab/data/icons/create/logistic.svg +1 -1
  15. datalab/gui/actionhandler.py +16 -2
  16. datalab/gui/h5io.py +25 -0
  17. datalab/gui/macroeditor.py +37 -6
  18. datalab/gui/main.py +62 -5
  19. datalab/gui/newobject.py +7 -0
  20. datalab/gui/objectview.py +18 -3
  21. datalab/gui/panel/base.py +89 -16
  22. datalab/gui/panel/macro.py +26 -0
  23. datalab/gui/plothandler.py +20 -2
  24. datalab/gui/processor/base.py +72 -26
  25. datalab/gui/processor/image.py +6 -2
  26. datalab/gui/processor/signal.py +10 -0
  27. datalab/gui/roieditor.py +2 -2
  28. datalab/locale/fr/LC_MESSAGES/datalab.mo +0 -0
  29. datalab/locale/fr/LC_MESSAGES/datalab.po +3288 -0
  30. datalab/objectmodel.py +1 -1
  31. datalab/tests/features/common/auto_analysis_recompute_unit_test.py +81 -0
  32. datalab/tests/features/common/coordutils_unit_test.py +212 -0
  33. datalab/tests/features/common/result_deletion_unit_test.py +121 -1
  34. datalab/tests/features/common/roi_plotitem_unit_test.py +4 -2
  35. datalab/tests/features/common/update_tree_robustness_test.py +65 -0
  36. datalab/tests/features/control/remoteclient_unit.py +10 -0
  37. datalab/tests/features/hdf5/h5workspace_unit_test.py +133 -0
  38. datalab/tests/features/image/roigrid_unit_test.py +75 -0
  39. datalab/tests/features/macro/macroeditor_unit_test.py +104 -3
  40. datalab/tests/features/signal/custom_signal_bug_unit_test.py +96 -0
  41. datalab/widgets/imagebackground.py +13 -4
  42. datalab/widgets/instconfviewer.py +2 -2
  43. datalab/widgets/signalcursor.py +7 -2
  44. datalab/widgets/signaldeltax.py +4 -1
  45. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/METADATA +3 -3
  46. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/RECORD +50 -43
  47. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/WHEEL +0 -0
  48. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/entry_points.txt +0 -0
  49. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/licenses/LICENSE +0 -0
  50. {datalab_platform-1.0.1.dist-info → datalab_platform-1.0.3.dist-info}/top_level.txt +0 -0
datalab/control/proxy.py CHANGED
@@ -300,6 +300,39 @@ class LocalProxy(BaseProxy):
300
300
  """
301
301
  self._datalab.add_annotations_from_items(items, refresh_plot, panel)
302
302
 
303
+ def load_h5_workspace(
304
+ self, h5files: list[str] | str, reset_all: bool = False
305
+ ) -> None:
306
+ """Load HDF5 workspace files without showing file dialog.
307
+
308
+ This method loads one or more DataLab native HDF5 files directly, bypassing
309
+ the file dialog. It is safe to call from the internal console or any context
310
+ where Qt dialogs would cause threading issues.
311
+
312
+ Args:
313
+ h5files: Path(s) to HDF5 file(s). Can be a single path string or a list
314
+ of paths
315
+ reset_all: If True, reset workspace before loading. Defaults to False.
316
+
317
+ Raises:
318
+ ValueError: if file is not a DataLab native HDF5 file
319
+ """
320
+ if isinstance(h5files, str):
321
+ h5files = [h5files]
322
+ self._datalab.load_h5_workspace(h5files, reset_all)
323
+
324
+ def save_h5_workspace(self, filename: str) -> None:
325
+ """Save workspace to HDF5 file without showing file dialog.
326
+
327
+ This method saves the current workspace to a DataLab native HDF5 file
328
+ directly, bypassing the file dialog. It is safe to call from the internal
329
+ console or any context where Qt dialogs would cause threading issues.
330
+
331
+ Args:
332
+ filename: Path to the output HDF5 file
333
+ """
334
+ self._datalab.save_h5_workspace(filename)
335
+
303
336
 
304
337
  @contextmanager
305
338
  def proxy_context(what: str) -> Generator[LocalProxy | RemoteProxy, None, None]:
datalab/control/remote.py CHANGED
@@ -84,6 +84,8 @@ class RemoteServer(QC.QThread):
84
84
  SIG_SAVE_TO_H5 = QC.Signal(str)
85
85
  SIG_OPEN_H5 = QC.Signal(list, bool, bool)
86
86
  SIG_IMPORT_H5 = QC.Signal(str, bool)
87
+ SIG_LOAD_H5_WORKSPACE = QC.Signal(list, bool)
88
+ SIG_SAVE_H5_WORKSPACE = QC.Signal(str)
87
89
  SIG_CALC = QC.Signal(str, object)
88
90
  SIG_RUN_MACRO = QC.Signal(str)
89
91
  SIG_STOP_MACRO = QC.Signal(str)
@@ -114,6 +116,8 @@ class RemoteServer(QC.QThread):
114
116
  self.SIG_SAVE_TO_H5.connect(win.save_to_h5_file)
115
117
  self.SIG_OPEN_H5.connect(win.open_h5_files)
116
118
  self.SIG_IMPORT_H5.connect(win.import_h5_file)
119
+ self.SIG_LOAD_H5_WORKSPACE.connect(win.load_h5_workspace)
120
+ self.SIG_SAVE_H5_WORKSPACE.connect(win.save_h5_workspace)
117
121
  self.SIG_CALC.connect(win.calc)
118
122
  self.SIG_RUN_MACRO.connect(win.run_macro)
119
123
  self.SIG_STOP_MACRO.connect(win.stop_macro)
@@ -268,6 +272,37 @@ class RemoteServer(QC.QThread):
268
272
  reset_all = False if reset_all is None else reset_all
269
273
  self.SIG_IMPORT_H5.emit(filename, reset_all)
270
274
 
275
+ @remote_call
276
+ def load_h5_workspace(self, h5files: list[str], reset_all: bool = False) -> None:
277
+ """Load native DataLab HDF5 workspace files without any GUI elements.
278
+
279
+ This method can be safely called from scripts as it does not create
280
+ any Qt widgets, dialogs, or progress bars.
281
+
282
+ Args:
283
+ h5files: List of native DataLab HDF5 filenames
284
+ reset_all: Reset all application data before importing. Defaults to False.
285
+
286
+ Raises:
287
+ ValueError: If a file is not a valid native DataLab HDF5 file
288
+ """
289
+ self.SIG_LOAD_H5_WORKSPACE.emit(h5files, reset_all)
290
+
291
+ @remote_call
292
+ def save_h5_workspace(self, filename: str) -> None:
293
+ """Save current workspace to a native DataLab HDF5 file without GUI elements.
294
+
295
+ This method can be safely called from scripts as it does not create
296
+ any Qt widgets, dialogs, or progress bars.
297
+
298
+ Args:
299
+ filename: HDF5 filename to save to
300
+
301
+ Raises:
302
+ IOError: If file cannot be saved
303
+ """
304
+ self.SIG_SAVE_H5_WORKSPACE.emit(filename)
305
+
271
306
  @remote_call
272
307
  def load_from_files(self, filenames: list[str]) -> None:
273
308
  """Open objects from files in current panel (signals/images).
Binary file
Binary file
@@ -3,5 +3,5 @@
3
3
  xmlns="http://www.w3.org/2000/svg">
4
4
  <rect style="fill:#696969;fill-opacity:1;stroke-width:0.547874" width="0.53186357" height="12.584304" x="6.7340684" y="0.70784807" />
5
5
  <rect style="fill:#696969;fill-opacity:1;stroke-width:0.54642" width="0.53186357" height="12.5176" x="6.7340684" y="-13.258801" transform="rotate(90)" />
6
- <path style="fill:none;stroke:#009966;stroke-width:0.35;stroke-linejoin:round" d="M 1,7 C 1.5,5.5 2,4.5 2.5,5.5 3,7 3.3,8.5 3.6,7.5 3.9,6 4.2,4 4.5,5 4.8,6.5 5,8.5 5.3,7.5 5.6,6 5.9,3.5 6.3,5 6.7,7 6.9,9.5 7.2,7.5 7.5,5 7.8,2 8.2,5 8.6,8 8.9,11 9.3,8 9.7,5 10.1,2 10.6,5.5 11.1,9 11.6,12 12.2,8.5 12.8,5 13,2" />
6
+ <path style="fill:none;stroke:#009966;stroke-width:0.35;stroke-linejoin:round" d="M 1,7 C 1.5,5.5 2,4.5 2.5,5.5 C 3,7 3.3,8.5 3.6,7.5 C 3.9,6 4.2,4 4.5,5 C 4.8,6.5 5,8.5 5.3,7.5 C 5.6,6 5.9,3.5 6.3,5 C 6.7,7 6.9,9.5 7.2,7.5 C 7.5,5 7.8,2 8.2,5 C 8.6,8 8.9,11 9.3,8 C 9.7,5 10.1,2 10.6,5.5 C 11.1,9 11.6,12 12.2,8.5 C 12.8,5 13,3.5 13,2" />
7
7
  </svg>
@@ -3,5 +3,5 @@
3
3
  xmlns="http://www.w3.org/2000/svg">
4
4
  <rect style="fill:#696969;fill-opacity:1;stroke-width:0.547874" width="0.53186357" height="12.584304" x="6.7340684" y="0.70784807" />
5
5
  <rect style="fill:#696969;fill-opacity:1;stroke-width:0.54642" width="0.53186357" height="12.5176" x="6.7340684" y="-13.258801" transform="rotate(90)" />
6
- <path style="fill:none;stroke:#9900cc;stroke-width:0.35;stroke-linejoin:round" d="M 1.5,12 C 2,12 2.5,12 3,11.9 3.5,11.8 4,11.6 4.5,11.2 5,10.8 5.5,10.2 6,9.5 6.5,8.5 7,7.2 7.5,5.5 8,4 8.5,3 9,2.5 9.5,2.2 10,2.1 10.5,2.05 11,2.02 11.5,2.01 12,2 12.5,2" />
6
+ <path style="fill:none;stroke:#9900cc;stroke-width:0.35;stroke-linejoin:round" d="M 1.5,12 C 2,12 2.5,12 3,11.9 C 3.5,11.8 4,11.6 4.5,11.2 C 5,10.8 5.5,10.2 6,9.5 C 6.5,8.5 7,7.2 7.5,5.5 C 8,4 8.5,3 9,2.5 C 9.5,2.2 10,2.1 10.5,2.05 C 11,2.02 11.5,2.01 12,2 L 12.5,2" />
7
7
  </svg>
@@ -50,6 +50,10 @@ from qtpy import QtWidgets as QW
50
50
  from datalab.adapters_metadata import GeometryAdapter, TableAdapter, have_results
51
51
  from datalab.config import Conf, _
52
52
  from datalab.gui import newobject
53
+ from datalab.gui.processor.base import (
54
+ clear_analysis_parameters,
55
+ extract_analysis_parameters,
56
+ )
53
57
  from datalab.widgets import fitdialog
54
58
 
55
59
  if TYPE_CHECKING:
@@ -361,6 +365,15 @@ class BaseActionHandler(metaclass=abc.ABCMeta):
361
365
  obj: Object containing the result
362
366
  adapter: Adapter for the result to delete
363
367
  """
368
+ # Check if this result matches the stored analysis parameters
369
+ # If so, clear them to prevent auto-recompute from attempting to
370
+ # recompute the deleted analysis when ROI changes
371
+ analysis_params = extract_analysis_parameters(obj)
372
+ if (
373
+ analysis_params is not None
374
+ and analysis_params.func_name == adapter.func_name
375
+ ):
376
+ clear_analysis_parameters(obj)
364
377
  adapter.remove_from(obj)
365
378
  # Update properties panel to reflect the removal
366
379
  if obj is self.panel.objview.get_current_object():
@@ -1269,7 +1282,9 @@ class SignalActionHandler(BaseActionHandler):
1269
1282
  with self.new_menu(_("Axis transformation")):
1270
1283
  self.action_for("transpose")
1271
1284
  self.action_for("reverse_x")
1272
- self.action_for("to_cartesian")
1285
+ self.action_for("replace_x_by_other_y")
1286
+ self.action_for("xy_mode")
1287
+ self.action_for("to_cartesian", separator=True)
1273
1288
  self.action_for("to_polar")
1274
1289
  with self.new_menu(_("Frequency filters"), icon_name="highpass.svg"):
1275
1290
  self.action_for("lowpass")
@@ -1364,7 +1379,6 @@ class SignalActionHandler(BaseActionHandler):
1364
1379
  separator=True,
1365
1380
  tip=_("Compute all stability features"),
1366
1381
  )
1367
- self.action_for("xy_mode", separator=True)
1368
1382
 
1369
1383
  # MARK: ANALYSIS
1370
1384
  with self.new_category(ActionCategory.ANALYSIS):
datalab/gui/h5io.py CHANGED
@@ -54,6 +54,31 @@ class H5InputOutput:
54
54
  panel.serialize_to_hdf5(writer)
55
55
  writer.close()
56
56
 
57
+ def open_file_headless(self, filename: str, reset_all: bool) -> bool:
58
+ """Open native DataLab HDF5 file without any GUI elements.
59
+
60
+ This method can be safely called from any thread (e.g., the console thread)
61
+ as it does not create any Qt widgets or dialogs.
62
+
63
+ Args:
64
+ filename: HDF5 filename
65
+ reset_all: Reset all application data before importing
66
+
67
+ Returns:
68
+ True if file was successfully opened as a native DataLab file,
69
+ False if the file format is not compatible (KeyError was raised)
70
+ """
71
+ try:
72
+ reader = NativeH5Reader(filename)
73
+ if reset_all:
74
+ self.mainwindow.reset_all()
75
+ for panel in self.mainwindow.panels:
76
+ panel.deserialize_from_hdf5(reader, reset_all)
77
+ reader.close()
78
+ return True
79
+ except KeyError:
80
+ return False
81
+
57
82
  def open_file(self, filename: str, import_all: bool, reset_all: bool) -> None:
58
83
  """Open HDF5 file"""
59
84
  progress = None
@@ -93,6 +93,7 @@ print("All done!")
93
93
  self.set_code(self.MACRO_SAMPLE)
94
94
  self.editor.modificationChanged.connect(self.modification_changed)
95
95
  self.process = None
96
+ self.__last_exit_code = None
96
97
 
97
98
  @property
98
99
  def title(self) -> str:
@@ -185,8 +186,20 @@ print("All done!")
185
186
  """
186
187
  global UNTITLED_NB # pylint: disable=global-statement
187
188
  UNTITLED_NB += 1
188
- untitled = _("Untitled")
189
- return f"{untitled} {UNTITLED_NB:02d}"
189
+ return f"macro_{UNTITLED_NB:02d}"
190
+
191
+ @staticmethod
192
+ def set_untitled_number(number: int) -> None:
193
+ """Set the untitled number counter
194
+
195
+ This is useful when loading macros from HDF5 or files to ensure
196
+ that the next untitled macro has a unique name.
197
+
198
+ Args:
199
+ number: New untitled number
200
+ """
201
+ global UNTITLED_NB # pylint: disable=global-statement
202
+ UNTITLED_NB = number
190
203
 
191
204
  def modification_changed(self, state: bool) -> None:
192
205
  """Method called when macro's editor modification state changed
@@ -207,8 +220,10 @@ print("All done!")
207
220
  Returns:
208
221
  Locale str
209
222
  """
210
- locale_codec = QC.QTextCodec.codecForLocale()
211
- return locale_codec.toUnicode(bytearr.data())
223
+ # Python 3 outputs UTF-8 by default, so we need to decode as UTF-8
224
+ # instead of using the system locale codec (which might be cp1252 on Windows)
225
+ utf8_codec = QC.QTextCodec.codecForName(b"UTF-8")
226
+ return utf8_codec.toUnicode(bytearr.data())
212
227
 
213
228
  def get_stdout(self) -> str:
214
229
  """Return standard output str
@@ -257,9 +272,16 @@ print("All done!")
257
272
  def run(self) -> None:
258
273
  """Run macro"""
259
274
  self.process = QC.QProcess()
260
- code = self.get_code().replace('"', "'")
275
+ code = self.get_code()
261
276
  datalab_path = osp.abspath(osp.join(osp.dirname(datalab.__file__), os.pardir))
262
- code = f"import sys; sys.path.append(r'{datalab_path}'){os.linesep}{code}"
277
+ # Reconfigure stdout/stderr to use UTF-8 encoding to avoid UnicodeEncodeError
278
+ # on Windows with locales that don't support all Unicode characters
279
+ # (e.g., cp1252)
280
+ code = (
281
+ f"import sys; sys.path.append(r'{datalab_path}'); "
282
+ f"sys.stdout.reconfigure(encoding='utf-8'); "
283
+ f"sys.stderr.reconfigure(encoding='utf-8'){os.linesep}{code}"
284
+ )
263
285
  env = QC.QProcessEnvironment()
264
286
  env.insert(execenv.XMLRPCPORT_ENV, str(execenv.xmlrpcport))
265
287
  sysenv = env.systemEnvironment()
@@ -305,6 +327,15 @@ print("All done!")
305
327
  exit_code: Exit code
306
328
  exit_status: Exit status
307
329
  """
330
+ self.__last_exit_code = exit_code
308
331
  self.print(_("# <== '%s' macro has finished") % self.title, eol_before=False)
309
332
  self.FINISHED.emit()
310
333
  self.process = None
334
+
335
+ def get_exit_code(self) -> int | None:
336
+ """Return last exit code of the macro process
337
+
338
+ Returns:
339
+ Last exit code or None if process has not finished yet
340
+ """
341
+ return self.__last_exit_code
datalab/gui/main.py CHANGED
@@ -1360,6 +1360,10 @@ class DLMainWindow(QW.QMainWindow, AbstractDLControl, metaclass=DLMainWindowMeta
1360
1360
  title += f" [{datalab.__version__}]"
1361
1361
  self.setWindowTitle(title)
1362
1362
 
1363
+ def is_modified(self) -> bool:
1364
+ """Return True if mainwindow is modified"""
1365
+ return self.__is_modified
1366
+
1363
1367
  def __add_dockwidget(self, child, title: str) -> QW.QDockWidget:
1364
1368
  """Add QDockWidget and toggleViewAction"""
1365
1369
  dockwidget, location = child.create_dockwidget(title)
@@ -1559,9 +1563,7 @@ class DLMainWindow(QW.QMainWindow, AbstractDLControl, metaclass=DLMainWindowMeta
1559
1563
  if not filename:
1560
1564
  return
1561
1565
  with qth.qt_try_loadsave_file(self, filename, "save"):
1562
- filename = self.__check_h5file(filename, "save")
1563
- self.h5inputoutput.save_file(filename)
1564
- self.set_modified(False)
1566
+ self.save_h5_workspace(filename)
1565
1567
 
1566
1568
  @remote_controlled
1567
1569
  def open_h5_files(
@@ -1617,6 +1619,8 @@ class DLMainWindow(QW.QMainWindow, AbstractDLControl, metaclass=DLMainWindowMeta
1617
1619
  )
1618
1620
  if answer == QW.QMessageBox.Yes:
1619
1621
  reset_all = True
1622
+ elif answer == QW.QMessageBox.No:
1623
+ reset_all = False
1620
1624
  elif answer == QW.QMessageBox.Ignore:
1621
1625
  Conf.io.h5_clear_workspace_ask.set(False)
1622
1626
  if h5files is None:
@@ -1665,6 +1669,59 @@ class DLMainWindow(QW.QMainWindow, AbstractDLControl, metaclass=DLMainWindowMeta
1665
1669
  self.__check_h5file(filename, "load")
1666
1670
  self.h5inputoutput.import_files(filenames, False, reset_all)
1667
1671
 
1672
+ @remote_controlled
1673
+ def load_h5_workspace(self, h5files: list[str], reset_all: bool = False) -> None:
1674
+ """Load native DataLab HDF5 workspace files without any GUI elements.
1675
+
1676
+ This method can be safely called from the internal console as it does not
1677
+ create any Qt widgets, dialogs, or progress bars. It is designed for
1678
+ programmatic use when loading DataLab workspace files.
1679
+
1680
+ .. warning::
1681
+
1682
+ This method only supports native DataLab HDF5 files. For importing
1683
+ arbitrary HDF5 files (non-native), use the GUI menu or macros with
1684
+ :class:`datalab.control.proxy.RemoteProxy`.
1685
+
1686
+ Args:
1687
+ h5files: List of native DataLab HDF5 filenames
1688
+ reset_all: Reset all application data before importing. Defaults to False.
1689
+
1690
+ Raises:
1691
+ ValueError: If a file is not a valid native DataLab HDF5 file
1692
+ """
1693
+ for idx, filename in enumerate(h5files):
1694
+ filename = self.__check_h5file(filename, "load")
1695
+ success = self.h5inputoutput.open_file_headless(
1696
+ filename, reset_all=(reset_all and idx == 0)
1697
+ )
1698
+ if not success:
1699
+ raise ValueError(
1700
+ f"File '{filename}' is not a native DataLab HDF5 file. "
1701
+ f"Use the GUI menu or a macro with RemoteProxy to import "
1702
+ f"arbitrary HDF5 files."
1703
+ )
1704
+ # Refresh panel trees after loading
1705
+ self.repopulate_panel_trees()
1706
+
1707
+ @remote_controlled
1708
+ def save_h5_workspace(self, filename: str) -> None:
1709
+ """Save current workspace to a native DataLab HDF5 file without GUI elements.
1710
+
1711
+ This method can be safely called from the internal console as it does not
1712
+ create any Qt widgets, dialogs, or progress bars. It is designed for
1713
+ programmatic use when saving DataLab workspace files.
1714
+
1715
+ Args:
1716
+ filename: HDF5 filename to save to
1717
+
1718
+ Raises:
1719
+ IOError: If file cannot be saved
1720
+ """
1721
+ filename = self.__check_h5file(filename, "save")
1722
+ self.h5inputoutput.save_file(filename)
1723
+ self.set_modified(False)
1724
+
1668
1725
  @remote_controlled
1669
1726
  def import_h5_file(self, filename: str, reset_all: bool | None = None) -> None:
1670
1727
  """Import HDF5 file into DataLab
@@ -2025,7 +2082,7 @@ class DLMainWindow(QW.QMainWindow, AbstractDLControl, metaclass=DLMainWindowMeta
2025
2082
  Returns:
2026
2083
  True if closed properly, False otherwise
2027
2084
  """
2028
- if not env.execenv.unattended and self.__is_modified:
2085
+ if not env.execenv.unattended and self.is_modified():
2029
2086
  answer = QW.QMessageBox.warning(
2030
2087
  self,
2031
2088
  _("Quit"),
@@ -2037,7 +2094,7 @@ class DLMainWindow(QW.QMainWindow, AbstractDLControl, metaclass=DLMainWindowMeta
2037
2094
  )
2038
2095
  if answer == QW.QMessageBox.Yes:
2039
2096
  self.save_to_h5_file()
2040
- if self.__is_modified:
2097
+ if self.is_modified():
2041
2098
  return False
2042
2099
  elif answer == QW.QMessageBox.Cancel:
2043
2100
  return False
datalab/gui/newobject.py CHANGED
@@ -122,6 +122,13 @@ def create_signal_gui(
122
122
  param = NewSignalParam()
123
123
  edit = True # Default to editing if no parameters provided
124
124
 
125
+ # CustomSignalParam requires edit mode to initialize the xyarray.
126
+ # Without this, if edit=False (the default in new_object), the setup_array
127
+ # call would be skipped, leaving xyarray as None, which would cause an
128
+ # AttributeError when trying to access param.xyarray.T later.
129
+ if isinstance(param, OrigCustomSignalParam):
130
+ edit = True
131
+
125
132
  if isinstance(param, OrigCustomSignalParam) and edit:
126
133
  p_init = NewSignalParam(_("Custom signal"))
127
134
  p_init.size = 10 # Set smaller default size for initial input
datalab/gui/objectview.py CHANGED
@@ -222,11 +222,26 @@ class SimpleObjectTree(QW.QTreeWidget):
222
222
  self.set_current_item_id(uuid)
223
223
 
224
224
  def update_tree(self) -> None:
225
- """Update tree"""
225
+ """Update tree
226
+
227
+ Note: If an item is not found in the tree, the tree is repopulated to ensure
228
+ consistency between the tree and the model. This can happen in rare cases when
229
+ objects are added to the model but the tree was not properly updated.
230
+ """
226
231
  for group in self.objmodel.get_groups():
227
- self.__update_item(self.get_item_from_id(get_uuid(group)), group)
232
+ group_item = self.get_item_from_id(get_uuid(group))
233
+ if group_item is None:
234
+ # Group item not found, repopulate tree to fix inconsistency
235
+ self.populate_tree()
236
+ return
237
+ self.__update_item(group_item, group)
228
238
  for obj in group:
229
- self.__update_item(self.get_item_from_id(get_uuid(obj)), obj)
239
+ obj_item = self.get_item_from_id(get_uuid(obj))
240
+ if obj_item is None:
241
+ # Object item not found, repopulate tree to fix inconsistency
242
+ self.populate_tree()
243
+ return
244
+ self.__update_item(obj_item, obj)
230
245
 
231
246
  def __add_to_group_item(
232
247
  self, obj: SignalObj | ImageObj, group_item: QW.QTreeWidgetItem
datalab/gui/panel/base.py CHANGED
@@ -79,6 +79,7 @@ from datalab.gui.newobject import (
79
79
  from datalab.gui.processor.base import (
80
80
  PROCESSING_PARAMETERS_OPTION,
81
81
  ProcessingParameters,
82
+ clear_analysis_parameters,
82
83
  extract_processing_parameters,
83
84
  insert_processing_parameters,
84
85
  )
@@ -216,6 +217,13 @@ class ObjectProp(QW.QWidget):
216
217
  self.analysis_parameters.setReadOnly(True)
217
218
  self.analysis_parameters.setFont(font)
218
219
 
220
+ # Track newly created objects to show Creation tab only once
221
+ self.newly_created_obj_uuid: str | None = None
222
+ # Track when analysis results were just computed
223
+ self.fresh_analysis_obj_uuid: str | None = None
224
+ # Track when object was just processed (1-to-1)
225
+ self.fresh_processing_obj_uuid: str | None = None
226
+
219
227
  self.tabwidget.addTab(
220
228
  self.processing_history, get_icon("history.svg"), _("History")
221
229
  )
@@ -370,11 +378,16 @@ class ObjectProp(QW.QWidget):
370
378
  self.properties.get()
371
379
  self.properties.apply_button.setEnabled(False)
372
380
 
373
- def update_properties_from(self, obj: SignalObj | ImageObj | None = None) -> None:
381
+ def update_properties_from(
382
+ self,
383
+ obj: SignalObj | ImageObj | None = None,
384
+ force_tab: Literal["creation", "processing", "analysis", None] | None = None,
385
+ ) -> None:
374
386
  """Update properties panel (properties, creation, processing) from object.
375
387
 
376
388
  Args:
377
389
  obj: Signal or Image object
390
+ force_tab: Force a specific tab to be current
378
391
  """
379
392
  self.properties.setDisabled(obj is None)
380
393
  if obj is None:
@@ -411,30 +424,56 @@ class ObjectProp(QW.QWidget):
411
424
  self.processing_scroll = None
412
425
 
413
426
  # Setup Creation and Processing tabs (if applicable)
414
- has_creation_tab = has_processing_tab = False
427
+ has_creation_tab = False
428
+ has_processing_tab = False
415
429
  if obj is not None:
416
430
  has_creation_tab = self.setup_creation_tab(obj)
417
- has_processing_tab = self.setup_processing_tab(obj)
431
+ has_processing_tab = self.setup_processing_tab(obj) # Processing tab setup
418
432
 
419
433
  # Trigger visibility update for History and Analysis parameters tabs
420
434
  # (will be called via textChanged signals, but we call explicitly
421
435
  # here to ensure initial state is correct)
422
436
  self._update_tab_visibility()
423
437
 
424
- # Handle priority regarding the tab to set as current:
425
- # 1. Analysis parameters if content exists
426
- # 2. Creation tab if it exists
427
- # 3. Processing tab if it exists
428
- # 4. Properties tab
429
- if has_analysis_parameters:
430
- self.tabwidget.setCurrentWidget(self.analysis_parameters)
431
- elif has_creation_tab:
438
+ # Determine which tab to show based on force_tab parameter:
439
+ # - If force_tab="creation" and Creation tab exists, show it
440
+ # - If force_tab="processing" and Processing tab exists, show it
441
+ # - If force_tab="analysis" and Analysis tab has content, show it
442
+ # - Otherwise, always show Properties tab (default behavior)
443
+ if force_tab == "creation" and has_creation_tab:
432
444
  self.tabwidget.setCurrentWidget(self.creation_scroll)
433
- elif has_processing_tab:
445
+ elif force_tab == "processing" and has_processing_tab:
434
446
  self.tabwidget.setCurrentWidget(self.processing_scroll)
447
+ elif force_tab == "analysis" and has_analysis_parameters:
448
+ self.tabwidget.setCurrentWidget(self.analysis_parameters)
435
449
  else:
450
+ # Default: always show Properties tab when switching objects
436
451
  self.tabwidget.setCurrentWidget(self.properties)
437
452
 
453
+ def mark_as_newly_created(self, obj: SignalObj | ImageObj) -> None:
454
+ """Mark object to show Creation tab on next selection.
455
+
456
+ Args:
457
+ obj: Object to mark
458
+ """
459
+ self.newly_created_obj_uuid = get_uuid(obj)
460
+
461
+ def mark_as_freshly_processed(self, obj: SignalObj | ImageObj) -> None:
462
+ """Mark object to show Processing tab on next selection.
463
+
464
+ Args:
465
+ obj: Object to mark
466
+ """
467
+ self.fresh_processing_obj_uuid = get_uuid(obj)
468
+
469
+ def mark_as_fresh_analysis(self, obj: SignalObj | ImageObj) -> None:
470
+ """Mark object to show Analysis tab on next selection.
471
+
472
+ Args:
473
+ obj: Object to mark
474
+ """
475
+ self.fresh_analysis_obj_uuid = get_uuid(obj)
476
+
438
477
  def get_changed_properties(self) -> dict[str, Any]:
439
478
  """Get dictionary of properties that have changed from original values.
440
479
 
@@ -1490,6 +1529,16 @@ class BaseDataPanel(AbstractPanel, Generic[TypeObj, TypeROI, TypeROIEditor]):
1490
1529
  obj.check_data()
1491
1530
  self.objmodel.add_object(obj, group_id)
1492
1531
 
1532
+ # Mark this object as newly created to show Creation tab on first selection
1533
+ # BUT: Don't overwrite if this object is already marked as freshly processed
1534
+ # or has fresh analysis results (those take precedence)
1535
+ obj_uuid = get_uuid(obj)
1536
+ if obj_uuid not in (
1537
+ self.objprop.fresh_processing_obj_uuid,
1538
+ self.objprop.fresh_analysis_obj_uuid,
1539
+ ):
1540
+ self.objprop.mark_as_newly_created(obj)
1541
+
1493
1542
  # Block signals to avoid updating the plot (unnecessary refresh)
1494
1543
  self.objview.blockSignals(True)
1495
1544
  self.objview.add_object_item(obj, group_id, set_current=set_current)
@@ -2419,7 +2468,26 @@ class BaseDataPanel(AbstractPanel, Generic[TypeObj, TypeROI, TypeROIEditor]):
2419
2468
  """
2420
2469
  selected_objects = self.objview.get_sel_objects(include_groups=True)
2421
2470
  selected_groups = self.objview.get_sel_groups()
2422
- self.objprop.update_properties_from(self.objview.get_current_object())
2471
+
2472
+ # Determine which tab to show based on object state
2473
+ current_obj = self.objview.get_current_object()
2474
+ force_tab = None
2475
+ if current_obj is not None:
2476
+ obj_uuid = get_uuid(current_obj)
2477
+ # Show Creation tab for newly created objects (only once)
2478
+ if obj_uuid == self.objprop.newly_created_obj_uuid:
2479
+ force_tab = "creation"
2480
+ self.objprop.newly_created_obj_uuid = None
2481
+ # Show Processing tab for freshly processed objects (only once)
2482
+ elif obj_uuid == self.objprop.fresh_processing_obj_uuid:
2483
+ force_tab = "processing"
2484
+ self.objprop.fresh_processing_obj_uuid = None
2485
+ # Show Analysis tab for objects with fresh analysis results
2486
+ elif obj_uuid == self.objprop.fresh_analysis_obj_uuid:
2487
+ force_tab = "analysis"
2488
+ self.objprop.fresh_analysis_obj_uuid = None
2489
+
2490
+ self.objprop.update_properties_from(current_obj, force_tab=force_tab)
2423
2491
  self.acthandler.selected_objects_changed(selected_groups, selected_objects)
2424
2492
  self.refresh_plot("selected", update_items, False)
2425
2493
 
@@ -2854,9 +2922,11 @@ class BaseDataPanel(AbstractPanel, Generic[TypeObj, TypeROI, TypeROIEditor]):
2854
2922
  dialog was accepted or not.
2855
2923
  """
2856
2924
  obj = self.objview.get_sel_objects(include_groups=True)[-1]
2857
- item = create_adapter_from_object(obj).make_item(
2858
- update_from=self.plothandler[get_uuid(obj)]
2859
- )
2925
+ # Use get() instead of [] to avoid KeyError if the plot item doesn't exist
2926
+ # (can happen when "auto refresh" is disabled or in "show first only" mode
2927
+ # where not all objects have plot items created yet)
2928
+ existing_item = self.plothandler.get(get_uuid(obj))
2929
+ item = create_adapter_from_object(obj).make_item(update_from=existing_item)
2860
2930
  roi_editor_class = self.get_roieditor_class() # pylint: disable=not-callable
2861
2931
  roi_editor = roi_editor_class(
2862
2932
  parent=self.parentWidget(),
@@ -3207,6 +3277,9 @@ class BaseDataPanel(AbstractPanel, Generic[TypeObj, TypeROI, TypeROIEditor]):
3207
3277
  # Remove all table and geometry results using adapter methods
3208
3278
  TableAdapter.remove_all_from(obj)
3209
3279
  GeometryAdapter.remove_all_from(obj)
3280
+ # Clear analysis parameters to prevent auto-recompute from
3281
+ # attempting to recompute deleted analyses when ROI changes
3282
+ clear_analysis_parameters(obj)
3210
3283
  if obj is self.objview.get_current_object():
3211
3284
  self.objprop.update_properties_from(obj)
3212
3285
  # Update action states to reflect the removal
@@ -204,6 +204,10 @@ class MacroPanel(AbstractPanel, DockableWidgetMixin):
204
204
  self.addWidget(widget)
205
205
  self.setStretchFactor(0, 2)
206
206
  self.setStretchFactor(1, 1)
207
+ # Set initial sizes: give more space to editor (70%) than console (30%)
208
+ # This ensures proper layout on first open
209
+ total_height = 600 # Default reasonable height
210
+ self.setSizes([int(total_height * 0.7), int(total_height * 0.3)])
207
211
 
208
212
  self.run_action = None
209
213
  self.stop_action = None
@@ -252,6 +256,8 @@ class MacroPanel(AbstractPanel, DockableWidgetMixin):
252
256
  # in a group but directly in the root of the HDF5 file
253
257
  obj = self.deserialize_object_from_hdf5(reader, name, reset_all)
254
258
  self.add_object(obj)
259
+ # Update untitled number counter to prevent duplicate names
260
+ self.update_untitled_counter()
255
261
 
256
262
  def __len__(self) -> int:
257
263
  """Return number of objects"""
@@ -298,6 +304,8 @@ class MacroPanel(AbstractPanel, DockableWidgetMixin):
298
304
  self.tabwidget.clear()
299
305
  self.__macros.clear()
300
306
  super().remove_all_objects()
307
+ # Reset untitled counter when clearing all macros
308
+ Macro.set_untitled_number(0)
301
309
 
302
310
  # ---- Macro panel API -------------------------------------------------------------
303
311
  def setup_actions(self) -> None:
@@ -572,9 +580,27 @@ class MacroPanel(AbstractPanel, DockableWidgetMixin):
572
580
  Conf.main.base_dir.set(filename)
573
581
  macro = self.add_macro()
574
582
  macro.from_file(filename)
583
+ # Update untitled number counter to prevent duplicate names
584
+ self.update_untitled_counter()
575
585
  return self.get_number_from_macro(macro)
576
586
  return -1
577
587
 
588
+ def update_untitled_counter(self) -> None:
589
+ """Update the untitled counter based on existing macro titles
590
+
591
+ This scans all macro titles to find the highest "macro_XX" number
592
+ and updates the global counter to prevent duplicate names.
593
+ """
594
+ max_untitled = 0
595
+ for macro in self.__macros:
596
+ # Match titles like "macro_01", "macro_02", etc.
597
+ match = re.match(r"macro_(\d+)", macro.title)
598
+ if match:
599
+ number = int(match.group(1))
600
+ max_untitled = max(max_untitled, number)
601
+ # Set the counter to the highest found number
602
+ Macro.set_untitled_number(max_untitled)
603
+
578
604
  def remove_macro(self, number_or_title: int | str | None = None) -> None:
579
605
  """Remove macro
580
606