`), and hands them over to
+ :meth:`_handle_imported_objects`.
+
+ Override in subclasses that import through their own browser or
+ progress dialog.
+
+ Args:
+ filename: HDF5 filename
+ reset_all: Reset all application data before importing
+ """
+ with qth.qt_try_loadsave_file(self, filename, "load"):
+ filename = self._check_h5file(filename, "load")
+ importer = H5Importer(filename)
+ objects = []
+ for node in importer.nodes:
+ if not node.is_supported():
+ continue
+ try:
+ obj = node.get_native_object()
+ if obj is not None:
+ objects.append(obj)
+ except Exception as exc: # pylint: disable=broad-except
+ qt_handle_error_message(self, exc)
+ importer.close()
+ self._handle_imported_objects(objects, bool(reset_all))
+
+ def reset_all(self) -> None:
+ """Reset all application data.
+
+ The base implementation is a **no-op**. Subclasses should override
+ this method to clear their data model (e.g. remove all objects
+ from panels).
+ """
+
+ def close_application(self) -> None:
+ """Close SigimaX application"""
+ self.close()
+
+ def raise_window(self) -> None:
+ """Raise SigimaX main window"""
+ bring_to_front(self)
+
+ def _about(self) -> None: # pragma: no cover
+ """About dialog box.
+
+ Override this method in subclasses to fully customize the About dialog.
+ """
+ self.check_stable_release()
+ conf = get_conf()
+ app_name = conf.app_name.get()
+ app_version = conf.app_version.get()
+ app_desc = conf.app_desc.get()
+ app_homeurl = conf.app_homeurl.get()
+ app_docurl = conf.app_docurl.get()
+ app_supporturl = conf.app_supporturl.get()
+ dev_by = conf.app_developer.get()
+ cprght = conf.app_copyright.get()
+
+ # -- Application header
+ about_parts = [f"{app_name} v{app_version}"]
+ if app_desc:
+ about_parts.append(f"
{app_desc}")
+ if dev_by:
+ about_parts.append(f"{dev_by}")
+ if cprght:
+ about_parts.append(f"
Copyright © {cprght}")
+
+ # -- Application links
+ links = []
+ if app_homeurl:
+ links.append(f'{_("Home page")}')
+ if app_docurl:
+ links.append(f'{_("Documentation")}')
+ if app_supporturl:
+ links.append(f'{_("Support")}')
+ if links:
+ about_parts.append("
" + " | ".join(links))
+
+ # -- SigimaX credits
+ sgmx_dev_by = _("Developed and maintained by DataLab open-source project team")
+ sgmx_cprght = "2023 DataLab Platform Developers"
+ about_parts.extend(
+ [
+ f'
Based on {MOD_TITLE} v{__version__}',
+ f"
{MOD_DESC}",
+ f"
{sgmx_dev_by}",
+ f"
Copyright © {sgmx_cprght}",
+ ]
+ )
+
+ QW.QMessageBox.about(
+ self,
+ _("About") + " " + app_name,
+ "".join(about_parts),
+ )
+
+ def _update_color_mode(self, startup: bool = False) -> None:
+ """Update color mode
+
+ Args:
+ startup: True if method is called during application startup (in that case,
+ color theme is applied only if mode != "auto")
+ """
+ mode = get_conf().color_mode.get()
+ if startup and mode == "auto":
+ guidata_qth.win32_fix_title_bar_background(self)
+ return
+
+ # Prevent Qt from refreshing the window when changing the color mode:
+ self.setUpdatesEnabled(False)
+
+ plotpy_config.set_plotpy_color_mode(mode)
+ get_conf().apply_plotpy_defaults()
+
+ if self.console is not None:
+ self.console.update_color_mode()
+
+ for dock in self.docks.values():
+ widget = dock.widget()
+ if isinstance(widget, DockablePlotWidget):
+ widget.update_color_mode()
+
+ self._update_extra_color_mode()
+
+ # Allow Qt to refresh the window:
+ self.setUpdatesEnabled(True)
+
+ def _update_extra_color_mode(self) -> None:
+ """Update the color mode of application-specific widgets.
+
+ Called with window updates disabled, after the console and the plot docks
+ have been updated. The base implementation is a no-op.
+ """
+
+ def _show_logviewer(self) -> None:
+ """Show error logs"""
+ logviewer.exec_sigimax_logviewer_dialog(self)
+
+ @staticmethod
+ def test_segfault_error() -> None:
+ """Generate errors (both fault and traceback)"""
+ import ctypes # pylint: disable=import-outside-toplevel
+
+ ctypes.string_at(0)
+ raise RuntimeError("!!! Testing RuntimeError !!!")
+
+ def show(self) -> None:
+ """Reimplement QMainWindow method"""
+ super().show()
+ if self.__old_size is not None:
+ self.resize(self.__old_size)
+
+ # ------Close window
+ def _get_save_before_quit_message(self) -> str:
+ """Return the confirmation message shown before closing modified data."""
+ return _(
+ "Do you want to save all signals and images "
+ "to an HDF5 file before quitting the application?"
+ )
+
+ def _close_managed_widgets(self) -> None:
+ """Close widgets owned by the generic application shell."""
+ if self.console is not None:
+ try:
+ self.console.close()
+ except RuntimeError:
+ # The Qt object may already be deleted when restarting a window
+ # in the same test process.
+ pass
+
+ def _cleanup_before_reset(self) -> None:
+ """Clean up derived services before resetting application data."""
+
+ def _cleanup_after_state_save(self) -> None:
+ """Finalize derived shutdown after saving the window state."""
+
+ def close_properly(self) -> bool:
+ """Close properly
+
+ Returns:
+ True if closed properly, False otherwise
+ """
+ if not execenv.unattended and self.is_modified():
+ answer = QW.QMessageBox.warning(
+ self,
+ _("Quit"),
+ self._get_save_before_quit_message(),
+ QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Cancel,
+ )
+ if answer == QW.QMessageBox.Yes:
+ self.save_to_h5_file()
+ if self.is_modified():
+ return False
+ elif answer == QW.QMessageBox.Cancel:
+ return False
+ self.hide() # Avoid showing individual widgets closing one after the other
+ self._close_managed_widgets()
+ self._cleanup_before_reset()
+ self.reset_all()
+ self._save_pos_size_and_state()
+ self._cleanup_after_state_save()
+
+ execenv.log(self, "closed properly")
+ return True
+
+ def closeEvent(self, event: QG.QCloseEvent) -> None:
+ """Reimplement QMainWindow method"""
+ if self.hide_on_close:
+ self.__old_size = self.size()
+ self.hide()
+ else:
+ if self.close_properly():
+ self.SIG_CLOSING.emit()
+ event.accept()
+ else:
+ event.ignore()
diff --git a/sigimax/tests/__init__.py b/sigimax/tests/__init__.py
new file mode 100644
index 0000000..be7f3df
--- /dev/null
+++ b/sigimax/tests/__init__.py
@@ -0,0 +1,92 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests (:mod:`sigimax.tests`)
+------------------------
+
+The SigimaX test suite is based on the `pytest `_ framework.
+
+The test suite modules are organized in subpackages according to their purpose.
+The following subpackages are available:
+"""
+
+from __future__ import annotations
+
+__all__ = [
+ "run",
+ "sigimax_test_app_context",
+]
+
+import os
+import os.path as osp
+import sys
+from contextlib import contextmanager
+from typing import Generator
+
+from guidata.guitest import run_testlauncher
+from sigima.tests import helpers
+
+import sigimax
+from sigimax.config import MOD_NAME
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils import qthelpers as qth
+
+# Add test data files and folders for the SigimaX module:
+helpers.add_test_module_path(MOD_NAME, osp.join("data", "tests"))
+
+
+@contextmanager
+def sigimax_test_app_context(
+ size: tuple[int, int] = None,
+ maximized: bool = False,
+ save: bool = False,
+ console: bool | None = None,
+ exec_loop: bool = True,
+) -> Generator[SGMXMainWindow, None, None]:
+ """Context manager handling SigimaX mainwindow creation and Qt event loop
+ with optional HDF5 file save and other options for testing purposes
+
+ Args:
+ size: mainwindow size (default: (950, 600))
+ maximized: whether to maximize mainwindow (default: False)
+ save: whether to save HDF5 file (default: False)
+ console: whether to show console (default: None)
+ exec_loop: whether to execute Qt event loop (default: True)
+ """
+ if size is None:
+ size = 1200, 700
+ with qth.sigimax_app_context(exec_loop=exec_loop):
+ win: SGMXMainWindow | None = None
+ try:
+ win = SGMXMainWindow(console=console)
+ if maximized:
+ win.showMaximized()
+ else:
+ width, height = size
+ win.resize(width, height)
+ win.showNormal()
+ win.show()
+ win.setObjectName(helpers.get_default_test_name()) # screenshot name
+ yield win
+ finally:
+ if save:
+ path = helpers.get_output_data_path("h5")
+ try:
+ os.remove(path)
+ win.save_to_h5_file(path)
+ except (FileNotFoundError, PermissionError):
+ pass
+ has_exception_occurred = sys.exc_info()[0] is not None
+ if not exec_loop or has_exception_occurred and win is not None:
+ # Closing main window properly
+ win.set_modified(False)
+ win.close()
+
+
+def run() -> None:
+ """Run SigimaX test launcher"""
+ run_testlauncher(sigimax)
+
+
+if __name__ == "__main__":
+ run()
diff --git a/sigimax/tests/adapters_plotpy/__init__.py b/sigimax/tests/adapters_plotpy/__init__.py
new file mode 100644
index 0000000..95a0a29
--- /dev/null
+++ b/sigimax/tests/adapters_plotpy/__init__.py
@@ -0,0 +1,9 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Adapter PlotPy tests (:mod:`sigimax.tests.adapters_plotpy`)
+-----------------------------------------------------------
+
+Unit tests for the :mod:`sigimax.adapters_plotpy` package, which adapts
+Sigima objects (signals, images, ROIs) to PlotPy plot items.
+"""
diff --git a/sigimax/tests/adapters_plotpy/test_coordutils.py b/sigimax/tests/adapters_plotpy/test_coordutils.py
new file mode 100644
index 0000000..810dd04
--- /dev/null
+++ b/sigimax/tests/adapters_plotpy/test_coordutils.py
@@ -0,0 +1,220 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Coordinate utilities unit tests
+--------------------------------
+
+Covers :mod:`sigimax.adapters_plotpy.coordutils`: rounding of signal/image
+coordinates and ROI parameters to a resolution-dependent precision.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from sigima.objects import (
+ CircularROI,
+ PolygonalROI,
+ RectangularROI,
+ SegmentROI,
+ create_image,
+ create_signal,
+)
+
+from sigimax.adapters_plotpy.coordutils import (
+ round_image_coords,
+ round_image_roi_param,
+ round_signal_coords,
+ round_signal_roi_param,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def test_round_signal_coords():
+ """Test signal coordinate rounding"""
+ # Create a signal with sampling period of 0.1
+ x = np.arange(0, 10, 0.1)
+ y = np.sin(x)
+ sig = create_signal("test", x, y)
+
+ # Test basic rounding
+ coords = [1.23456789, 5.87654321]
+ rounded = round_signal_coords(sig, coords)
+ # With sampling period 0.1 and precision_factor 0.1, precision = 0.01
+ # Should round to 2 decimal places
+ assert rounded == [1.23, 5.88]
+
+ # Test with custom precision factor
+ rounded = round_signal_coords(sig, coords, precision_factor=1.0)
+ # precision = 0.1, should round to 1 decimal place
+ assert rounded == [1.2, 5.9]
+
+ # Test with signal that has too few points
+ sig_short = create_signal("test", np.array([1.0]), np.array([2.0]))
+ coords = [1.23456789]
+ rounded = round_signal_coords(sig_short, coords)
+ # Should return coords as-is
+ assert rounded == coords
+
+ # Test with constant x (zero sampling period)
+ sig_const = create_signal("test", np.ones(10), np.ones(10))
+ rounded = round_signal_coords(sig_const, coords)
+ # Should return coords as-is
+ assert rounded == coords
+
+
+def test_round_image_coords():
+ """Test image coordinate rounding"""
+ # Create an image with dx=dy=1.0 (uniform)
+ data = np.ones((100, 100))
+ img = create_image("test", data)
+
+ # Test basic rounding
+ coords = [10.123456, 20.987654, 30.555555, 40.444444]
+ rounded = round_image_coords(img, coords)
+ # With pixel spacing 1.0 and precision_factor 0.1, precision = 0.1
+ # Should round to 1 decimal place
+ assert rounded == [10.1, 21.0, 30.6, 40.4]
+
+ # Test with custom precision factor
+ rounded = round_image_coords(img, coords, precision_factor=1.0)
+ # precision = 1.0, should round to 0 decimal places
+ assert rounded == [10.0, 21.0, 31.0, 40.0]
+
+ # Test with empty coords
+ assert not round_image_coords(img, [])
+
+ # Test error for odd number of coordinates
+ with pytest.raises(ValueError, match="even number of elements"):
+ round_image_coords(img, [1.0, 2.0, 3.0])
+
+
+def test_round_signal_roi_param():
+ """Test signal ROI parameter rounding"""
+ # Create a signal with sampling period of 0.1
+ x = np.arange(0, 10, 0.1)
+ y = np.sin(x)
+ sig = create_signal("test", x, y)
+
+ # Create a segment ROI
+ roi = SegmentROI([1.23456789, 5.87654321], False)
+ param = roi.to_param(sig, 0)
+
+ # Round the parameter
+ round_signal_roi_param(sig, param)
+
+ # Check that coordinates are rounded
+ assert param.xmin == 1.23
+ assert param.xmax == 5.88
+
+
+def test_round_image_roi_param_rectangle():
+ """Test image ROI parameter rounding for rectangular ROI"""
+ # Create an image with dx=dy=1.0
+ data = np.ones((100, 100))
+ img = create_image("test", data)
+
+ # Create a rectangular ROI with floating-point errors
+ roi = RectangularROI([10.0, 20.0, 50.29999999999995, 75.19999999999999], False)
+ param = roi.to_param(img, 0)
+
+ # Verify we have the floating-point errors before rounding
+ assert param.dx == 50.29999999999995
+ assert param.dy == 75.19999999999999
+
+ # Round the parameter
+ round_image_roi_param(img, param)
+
+ # Check that coordinates are rounded
+ assert param.x0 == 10.0
+ assert param.y0 == 20.0
+ assert param.dx == 50.3
+ assert param.dy == 75.2
+
+
+def test_round_image_roi_param_circle():
+ """Test image ROI parameter rounding for circular ROI"""
+ # Create an image with dx=dy=1.0
+ data = np.ones((100, 100))
+ img = create_image("test", data)
+
+ # Create a circular ROI with floating-point errors
+ roi = CircularROI([50.123456, 50.987654, 25.555555], False)
+ param = roi.to_param(img, 0)
+
+ # Round the parameter
+ round_image_roi_param(img, param)
+
+ # Check that coordinates are rounded
+ assert param.xc == 50.1
+ assert param.yc == 51.0
+ assert param.r == 25.6
+
+
+def test_round_image_roi_param_polygon():
+ """Test image ROI parameter rounding for polygonal ROI"""
+ # Create an image with dx=dy=1.0
+ data = np.ones((100, 100))
+ img = create_image("test", data)
+
+ # Create a polygonal ROI with floating-point errors
+ coords = [10.123456, 20.987654, 30.555555, 40.444444, 50.111111, 60.999999]
+ roi = PolygonalROI(coords, False)
+ param = roi.to_param(img, 0)
+
+ # Round the parameter
+ round_image_roi_param(img, param)
+
+ # Check that coordinates are rounded
+ expected = np.array([10.1, 21.0, 30.6, 40.4, 50.1, 61.0])
+ np.testing.assert_array_equal(param.points, expected)
+
+
+def test_round_coords_non_uniform_image():
+ """Test coordinate rounding for non-uniform image coordinates"""
+ # Create an image with non-uniform coordinates
+ data = np.ones((10, 10))
+ img = create_image("test", data)
+ # Set non-uniform coordinates
+ img.xcoords = np.array([0, 1, 3, 6, 10, 15, 21, 28, 36, 45]) # varying spacing
+ img.ycoords = np.array([0, 2, 4, 6, 8, 10, 12, 14, 16, 18]) # uniform spacing of 2
+
+ # Test rounding - should use average spacing
+ coords = [5.123456, 7.987654, 25.555555, 13.444444]
+ rounded = round_image_coords(img, coords)
+
+ # Average dx ≈ 5.0, average dy = 2.0
+ # With precision_factor=0.1: precision_x=0.5, precision_y=0.2
+ # Should round to 1 decimal place for both
+ assert rounded == [5.1, 8.0, 25.6, 13.4]
+
+
+def test_round_coords_preserves_structure():
+ """Test that coordinate rounding preserves the structure of coordinates"""
+ # Create an image
+ data = np.ones((100, 100))
+ img = create_image("test", data)
+
+ # Test with multiple coordinate pairs
+ coords = [
+ 10.111,
+ 20.222,
+ 30.333,
+ 40.444,
+ 50.555,
+ 60.666,
+ 70.777,
+ 80.888,
+ ]
+ rounded = round_image_coords(img, coords)
+
+ # Should have same length
+ assert len(rounded) == len(coords)
+
+ # Each coordinate should be rounded independently
+ assert all(isinstance(c, (int, float)) for c in rounded)
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/adapters_plotpy/test_factory.py b/sigimax/tests/adapters_plotpy/test_factory.py
new file mode 100644
index 0000000..c6f1c9a
--- /dev/null
+++ b/sigimax/tests/adapters_plotpy/test_factory.py
@@ -0,0 +1,125 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tier 1 — Factory and pure-unit tests (no Qt)
+---------------------------------------------
+
+Tests for :func:`create_adapter_from_object`, unsupported types,
+:meth:`iterate_metadata_shape_items` default hook, and annotation roundtrip
+logic that do **not** require a running Qt application.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sigima.objects import (
+ CircularROI,
+ PolygonalROI,
+ RectangularROI,
+ SegmentROI,
+ create_image_roi,
+ create_signal_roi,
+)
+from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal
+
+from sigimax.adapters_plotpy.converters import create_adapter_from_object
+from sigimax.adapters_plotpy.objects.image import ImageObjPlotPyAdapter
+from sigimax.adapters_plotpy.objects.signal import SignalObjPlotPyAdapter
+from sigimax.adapters_plotpy.roi.image import (
+ CircularROIPlotPyAdapter,
+ ImageROIPlotPyAdapter,
+ PolygonalROIPlotPyAdapter,
+ RectangularROIPlotPyAdapter,
+)
+from sigimax.adapters_plotpy.roi.signal import (
+ SegmentROIPlotPyAdapter,
+ SignalROIPlotPyAdapter,
+)
+
+pytestmark = pytest.mark.unit
+
+__all__ = [
+ "test_factory_core_types",
+ "test_factory_unsupported_type",
+ "test_iterate_metadata_hook_default",
+]
+
+
+# ---------------------------------------------------------------------------
+# test_factory_core_types
+# ---------------------------------------------------------------------------
+
+_EXPECTED_ADAPTERS = [
+ # (factory_input_builder, expected_adapter_class)
+ (create_paracetamol_signal, SignalObjPlotPyAdapter),
+ (create_multigaussian_image, ImageObjPlotPyAdapter),
+ (
+ lambda: create_signal_roi([7.5, 10.0]),
+ SignalROIPlotPyAdapter,
+ ),
+ (
+ lambda: SegmentROI([7.5, 10.0], indices=False),
+ SegmentROIPlotPyAdapter,
+ ),
+ (
+ lambda: RectangularROI([10, 20, 30, 40], indices=False),
+ RectangularROIPlotPyAdapter,
+ ),
+ (
+ lambda: CircularROI([10, 20, 5], indices=False),
+ CircularROIPlotPyAdapter,
+ ),
+ (
+ lambda: PolygonalROI([0, 0, 10, 0, 5, 8], indices=False),
+ PolygonalROIPlotPyAdapter,
+ ),
+ (
+ lambda: create_image_roi("rectangle", [10, 20, 30, 40]),
+ ImageROIPlotPyAdapter,
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ "builder, expected_cls",
+ _EXPECTED_ADAPTERS,
+ ids=[
+ "SignalObj",
+ "ImageObj",
+ "SignalROI",
+ "SegmentROI",
+ "RectangularROI",
+ "CircularROI",
+ "PolygonalROI",
+ "ImageROI",
+ ],
+)
+def test_factory_core_types(builder, expected_cls):
+ """create_adapter_from_object() returns the correct adapter for each type."""
+ obj = builder()
+ adapter = create_adapter_from_object(obj)
+ assert isinstance(adapter, expected_cls)
+
+
+# ---------------------------------------------------------------------------
+# test_factory_unsupported_type
+# ---------------------------------------------------------------------------
+
+
+def test_factory_unsupported_type():
+ """create_adapter_from_object() raises TypeError for unknown types."""
+ with pytest.raises(TypeError, match="Unsupported object type"):
+ create_adapter_from_object("not a sigima object")
+
+
+# ---------------------------------------------------------------------------
+# test_iterate_metadata_hook_default
+# ---------------------------------------------------------------------------
+
+
+def test_iterate_metadata_hook_default():
+ """BaseObjPlotPyAdapter.iterate_metadata_shape_items() yields nothing."""
+ sig = create_paracetamol_signal()
+ adapter = create_adapter_from_object(sig)
+ items = list(adapter.iterate_metadata_shape_items("some_key", "val", "%g", True))
+ assert not items
diff --git a/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py b/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py
new file mode 100644
index 0000000..5863a2c
--- /dev/null
+++ b/sigimax/tests/adapters_plotpy/test_iterate_shape_items.py
@@ -0,0 +1,143 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tier 4 — iterate_shape_items integration tests (offscreen Qt)
+--------------------------------------------------------------
+
+Tests that verify :meth:`iterate_shape_items` yields the expected plot items
+when the underlying object carries ROI metadata, annotations, or neither.
+
+Also includes the annotation roundtrip test (conceptually Tier 1 but needs Qt
+because PlotPy items are QGraphicsObject subclasses).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from guidata.qthelpers import qt_app_context
+from plotpy.items import AnnotatedRectangle, AnnotatedXRange
+from sigima.objects import create_image_roi, create_signal_roi
+from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal
+
+from sigimax.adapters_plotpy.converters import create_adapter_from_object
+
+pytestmark = pytest.mark.gui
+
+__all__ = [
+ "test_annotations_roundtrip",
+ "test_iterate_shape_items_empty",
+ "test_iterate_shape_items_with_annotations",
+ "test_iterate_shape_items_with_roi",
+]
+
+
+# ---------------------------------------------------------------------------
+# Annotation roundtrip (Tier 1 concept, needs Qt for PlotPy items)
+# ---------------------------------------------------------------------------
+
+
+def test_annotations_roundtrip():
+ """add_annotations_from_items() → get_items() preserves annotation data."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+ adapter = create_adapter_from_object(sig)
+
+ # Create a PlotPy annotation item
+ x0, y0, x1, y1 = 1.0, 2.0, 5.0, 8.0
+ rect = AnnotatedRectangle(x0, y0, x1, y1)
+
+ # Store via adapter
+ adapter.add_annotations_from_items([rect])
+ assert sig.has_annotations()
+
+ # Retrieve via annotation adapter
+ recovered = adapter.annotation_adapter.get_items()
+ assert len(recovered) == 1
+ rec_rect = recovered[0]
+ assert isinstance(rec_rect, AnnotatedRectangle)
+
+ # Verify coordinates roundtrip
+ r_x0, r_y0, r_x1, r_y1 = rec_rect.get_rect()
+ np.testing.assert_allclose([r_x0, r_y0, r_x1, r_y1], [x0, y0, x1, y1])
+
+
+# ---------------------------------------------------------------------------
+# iterate_shape_items — with ROI
+# ---------------------------------------------------------------------------
+
+
+def test_iterate_shape_items_with_roi():
+ """Object with ROI metadata → iterate_shape_items yields ROI plot items."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+
+ # Attach a signal ROI (physical coordinates)
+ xmin, xmax = float(sig.x[50]), float(sig.x[100])
+ sig.roi = create_signal_roi([xmin, xmax])
+
+ adapter = create_adapter_from_object(sig)
+ items = list(adapter.iterate_shape_items(editable=False))
+
+ # At least one item should have been produced for the ROI
+ assert len(items) >= 1
+ # The item should be an AnnotatedXRange (signal ROI)
+ assert isinstance(items[0], AnnotatedXRange)
+
+
+def test_iterate_shape_items_with_image_roi():
+ """Image with ROI metadata → iterate_shape_items yields ROI plot items."""
+ with qt_app_context(exec_loop=False):
+ img = create_multigaussian_image()
+
+ # Attach a rectangular ROI (physical coordinates)
+ img.roi = create_image_roi("rectangle", [2.0, 3.0, 4.0, 5.0])
+
+ adapter = create_adapter_from_object(img)
+ items = list(adapter.iterate_shape_items(editable=False))
+
+ assert len(items) >= 1
+ assert isinstance(items[0], AnnotatedRectangle)
+
+
+# ---------------------------------------------------------------------------
+# iterate_shape_items — with annotations
+# ---------------------------------------------------------------------------
+
+
+def test_iterate_shape_items_with_annotations():
+ """Object with annotations → iterate_shape_items yields annotation items."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+ adapter = create_adapter_from_object(sig)
+
+ # Add an annotation
+ rect = AnnotatedRectangle(0.0, 0.0, 5.0, 5.0)
+ adapter.add_annotations_from_items([rect])
+
+ items = list(adapter.iterate_shape_items(editable=False))
+
+ # Should contain at least the annotation item
+ assert len(items) >= 1
+ # Find the AnnotatedRectangle among yielded items
+ rects = [it for it in items if isinstance(it, AnnotatedRectangle)]
+ assert len(rects) == 1
+
+
+# ---------------------------------------------------------------------------
+# iterate_shape_items — empty
+# ---------------------------------------------------------------------------
+
+
+def test_iterate_shape_items_empty():
+ """No metadata → iterate_shape_items yields nothing."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+ # Ensure no ROI and no annotations
+ sig.roi = None
+ sig.annotations = ""
+
+ adapter = create_adapter_from_object(sig)
+ items = list(adapter.iterate_shape_items(editable=False))
+
+ assert not items
diff --git a/sigimax/tests/adapters_plotpy/test_plot_items.py b/sigimax/tests/adapters_plotpy/test_plot_items.py
new file mode 100644
index 0000000..21b41c5
--- /dev/null
+++ b/sigimax/tests/adapters_plotpy/test_plot_items.py
@@ -0,0 +1,154 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tier 2 — Plot-item tests (offscreen Qt)
+-----------------------------------------
+
+Tests that exercise :meth:`make_item`, :meth:`update_item`, and the
+plot-item-parameter roundtrip for signals and images.
+
+Qt is required because PlotPy plot items are QGraphicsObject subclasses.
+The tests use ``guidata.qthelpers.qt_app_context(exec_loop=False)`` so no
+event-loop interaction is needed.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+from guidata.qthelpers import qt_app_context
+from plotpy.items import CurveItem, MaskedXYImageItem
+from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal
+
+from sigimax.adapters_plotpy.converters import create_adapter_from_object
+from sigimax.adapters_plotpy.objects.image import ImageObjPlotPyAdapter
+from sigimax.adapters_plotpy.objects.signal import SignalObjPlotPyAdapter
+
+pytestmark = pytest.mark.gui
+
+__all__ = [
+ "test_image_make_item",
+ "test_image_update_item",
+ "test_signal_make_item",
+ "test_signal_metadata_options",
+ "test_signal_update_item",
+]
+
+
+# ---------------------------------------------------------------------------
+# Signal — make_item
+# ---------------------------------------------------------------------------
+
+
+def test_signal_make_item():
+ """SignalObjPlotPyAdapter.make_item() returns a CurveItem with correct data."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+ adapter: SignalObjPlotPyAdapter = create_adapter_from_object(sig)
+ item = adapter.make_item()
+
+ assert isinstance(item, CurveItem)
+
+ # Verify that the plot item carries the expected data
+ x_item, y_item = item.get_data()[:2]
+ x_obj, y_obj = sig.xydata[:2]
+ np.testing.assert_array_equal(x_item, x_obj.real)
+ np.testing.assert_array_equal(y_item, y_obj.real)
+
+
+# ---------------------------------------------------------------------------
+# Image — make_item
+# ---------------------------------------------------------------------------
+
+
+def test_image_make_item():
+ """
+ ImageObjPlotPyAdapter.make_item() returns a MaskedXYImageItem
+ with correct data.
+ """
+ with qt_app_context(exec_loop=False):
+ img = create_multigaussian_image()
+ adapter: ImageObjPlotPyAdapter = create_adapter_from_object(img)
+ item = adapter.make_item()
+
+ assert isinstance(item, MaskedXYImageItem)
+
+ # Verify that the underlying data matches
+ item_data = item.data
+ np.testing.assert_array_equal(item_data, img.data.real)
+
+
+# ---------------------------------------------------------------------------
+# Signal — update_item
+# ---------------------------------------------------------------------------
+
+
+def test_signal_update_item():
+ """update_item() refreshes an existing CurveItem with new data."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+ adapter: SignalObjPlotPyAdapter = create_adapter_from_object(sig)
+ item = adapter.make_item()
+
+ # Mutate the signal data
+ sig.y = sig.y * 2.0
+ adapter.update_item(item, data_changed=True)
+
+ x_item, y_item = item.get_data()[:2]
+ x_obj, y_obj = sig.xydata[:2]
+ np.testing.assert_array_equal(x_item, x_obj.real)
+ np.testing.assert_array_equal(y_item, y_obj.real)
+
+
+# ---------------------------------------------------------------------------
+# Image — update_item
+# ---------------------------------------------------------------------------
+
+
+def test_image_update_item():
+ """update_item() refreshes an existing MaskedXYImageItem with new data."""
+ with qt_app_context(exec_loop=False):
+ img = create_multigaussian_image()
+ adapter: ImageObjPlotPyAdapter = create_adapter_from_object(img)
+ item = adapter.make_item()
+
+ # Mutate the image data
+ img.data = (img.data * 0.5).astype(img.data.dtype)
+
+ # update_item() calls item.plot().update_colormap_axis() which requires
+ # the item to be attached to a plot widget. Patch item.plot to avoid the
+ # AttributeError in this headless test.
+
+ item.plot = MagicMock()
+ adapter.update_item(item, data_changed=True)
+
+ np.testing.assert_array_equal(item.data, img.data.real)
+
+
+# ---------------------------------------------------------------------------
+# Signal — metadata options roundtrip
+# ---------------------------------------------------------------------------
+
+
+def test_signal_metadata_options():
+ """update_plot_item_parameters ↔ update_metadata_from_plot_item preserves data."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+ adapter: SignalObjPlotPyAdapter = create_adapter_from_object(sig)
+ item = adapter.make_item()
+
+ # Snapshot metadata that was written into the item
+ adapter.update_plot_item_parameters(item)
+
+ # Read metadata back from the item
+ adapter.update_metadata_from_plot_item(item)
+
+ # Create a fresh item from the same (now updated) object
+ item2 = adapter.make_item()
+
+ # The two items should have identical curve parameters
+ assert item.param.label == item2.param.label
+ assert item.param.line.color == item2.param.line.color
+ assert item.param.line.style == item2.param.line.style
diff --git a/sigimax/tests/adapters_plotpy/test_roi_roundtrip.py b/sigimax/tests/adapters_plotpy/test_roi_roundtrip.py
new file mode 100644
index 0000000..a0dbc78
--- /dev/null
+++ b/sigimax/tests/adapters_plotpy/test_roi_roundtrip.py
@@ -0,0 +1,132 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tier 3 — ROI conversion roundtrip tests (offscreen Qt)
+-------------------------------------------------------
+
+Tests that verify converting a Sigima ROI to a PlotPy plot item and back
+preserves the original coordinates for both signal and image ROI types.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from guidata.qthelpers import qt_app_context
+from sigima.objects import (
+ CircularROI,
+ PolygonalROI,
+ RectangularROI,
+ SegmentROI,
+ create_image_roi,
+ create_signal_roi,
+)
+from sigima.tests.data import create_multigaussian_image, create_paracetamol_signal
+
+from sigimax.adapters_plotpy.converters import (
+ plotitem_to_singleroi,
+ singleroi_to_plotitem,
+)
+
+pytestmark = pytest.mark.gui
+
+__all__ = [
+ "test_image_roi_roundtrip_circle",
+ "test_image_roi_roundtrip_polygon",
+ "test_image_roi_roundtrip_rectangle",
+ "test_signal_roi_roundtrip",
+]
+
+
+# ---------------------------------------------------------------------------
+# Signal ROI roundtrip
+# ---------------------------------------------------------------------------
+
+
+def test_signal_roi_roundtrip():
+ """Signal SegmentROI → plot item → SegmentROI preserves coordinates."""
+ with qt_app_context(exec_loop=False):
+ sig = create_paracetamol_signal()
+
+ # Use physical coordinates
+ xmin, xmax = float(sig.x[50]), float(sig.x[100])
+ roi = create_signal_roi([xmin, xmax])
+ original = roi.get_single_roi(0)
+
+ # ROI → plot item
+ item = singleroi_to_plotitem(original, sig)
+
+ # Plot item → ROI
+ recovered = plotitem_to_singleroi(item, sig)
+
+ assert isinstance(recovered, SegmentROI)
+ orig_coords = original.get_physical_coords(sig)
+ rec_coords = recovered.get_physical_coords(sig)
+ np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6)
+
+
+# ---------------------------------------------------------------------------
+# Image ROI roundtrip — Rectangle
+# ---------------------------------------------------------------------------
+
+
+def test_image_roi_roundtrip_rectangle():
+ """Image RectangularROI → plot item → RectangularROI preserves coords."""
+ with qt_app_context(exec_loop=False):
+ img = create_multigaussian_image()
+
+ roi = create_image_roi("rectangle", [2.0, 3.0, 4.0, 5.0])
+ original = roi.get_single_roi(0)
+
+ item = singleroi_to_plotitem(original, img)
+ recovered = plotitem_to_singleroi(item, img)
+
+ assert isinstance(recovered, RectangularROI)
+ orig_coords = np.array(original.get_physical_coords(img), dtype=float)
+ rec_coords = np.array(recovered.get_physical_coords(img), dtype=float)
+ np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6)
+
+
+# ---------------------------------------------------------------------------
+# Image ROI roundtrip — Circle
+# ---------------------------------------------------------------------------
+
+
+def test_image_roi_roundtrip_circle():
+ """Image CircularROI → plot item → CircularROI preserves coords."""
+ with qt_app_context(exec_loop=False):
+ img = create_multigaussian_image()
+
+ roi = create_image_roi("circle", [0.0, 0.0, 3.0])
+ original = roi.get_single_roi(0)
+
+ item = singleroi_to_plotitem(original, img)
+ recovered = plotitem_to_singleroi(item, img)
+
+ assert isinstance(recovered, CircularROI)
+ orig_coords = np.array(original.get_physical_coords(img), dtype=float)
+ rec_coords = np.array(recovered.get_physical_coords(img), dtype=float)
+ np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6)
+
+
+# ---------------------------------------------------------------------------
+# Image ROI roundtrip — Polygon
+# ---------------------------------------------------------------------------
+
+
+def test_image_roi_roundtrip_polygon():
+ """Image PolygonalROI → plot item → PolygonalROI preserves coords."""
+ with qt_app_context(exec_loop=False):
+ img = create_multigaussian_image()
+
+ coords = [0.0, 0.0, 5.0, 0.0, 5.0, 5.0, 0.0, 5.0]
+ roi = create_image_roi("polygon", coords)
+ original = roi.get_single_roi(0)
+
+ item = singleroi_to_plotitem(original, img)
+ recovered = plotitem_to_singleroi(item, img)
+
+ assert isinstance(recovered, PolygonalROI)
+ orig_coords = np.array(original.get_physical_coords(img), dtype=float)
+ rec_coords = np.array(recovered.get_physical_coords(img), dtype=float)
+ np.testing.assert_allclose(rec_coords, orig_coords, rtol=1e-6)
diff --git a/sigimax/tests/config/__init__.py b/sigimax/tests/config/__init__.py
new file mode 100644
index 0000000..792d600
--- /dev/null
+++ b/sigimax/tests/config/__init__.py
@@ -0,0 +1 @@
+#
diff --git a/sigimax/tests/config/test_config_fields.py b/sigimax/tests/config/test_config_fields.py
new file mode 100644
index 0000000..ee4c26a
--- /dev/null
+++ b/sigimax/tests/config/test_config_fields.py
@@ -0,0 +1,491 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for config.py option field classes and helpers
+----------------------------------------------------
+
+Covers:
+- TupleOptionField: set/get, list→tuple conversion, validation
+- FontOptionField: set/get, validation
+- AppOptionsContainer: to_dict/from_dict roundtrip, reset_to_defaults
+- get_old_log_fname, is_frozen, get_mod_source_dir
+- SigimaXOptions.list_options completeness
+"""
+
+from __future__ import annotations
+
+import os.path as osp
+import sys
+import tempfile
+
+import guidata.dataset as gds
+import pytest
+
+from sigimax.config import (
+ AppOptionsContainer,
+ ConfigPathOptionField,
+ DataSetOptionField,
+ EnumOptionField,
+ FontOptionField,
+ SigimaXOptions,
+ TupleOptionField,
+ TypedOptionField,
+ WorkingDirOptionField,
+ get_mod_source_dir,
+ get_old_log_fname,
+ is_frozen,
+)
+
+pytestmark = pytest.mark.unit
+
+
+class _SampleParam(gds.DataSet):
+ """Simple DataSet used to exercise DataSetOptionField."""
+
+ value = gds.IntItem("Value", default=3)
+
+
+# ---------------------------------------------------------------------------
+# Helpers: minimal container for isolated field tests
+# ---------------------------------------------------------------------------
+
+
+class _MiniContainer(AppOptionsContainer):
+ APP_NAME = "MiniTest"
+
+ def __init__(self):
+ super().__init__()
+ self.changed_options = []
+ self.my_tuple = TupleOptionField(
+ self, "my_tuple", default=(10, 20), description="A tuple option"
+ )
+ self.my_font = FontOptionField(
+ self, "my_font", default=("Arial", 12, False), description="A font option"
+ )
+ self.my_enum = EnumOptionField(
+ self,
+ "my_enum",
+ default="a",
+ choices=["a", "b", "c"],
+ description="An enum option",
+ )
+ self.my_str = TypedOptionField(
+ self, "my_str", default="hello", expected_type=str, description="A string"
+ )
+
+ def option_changed(self, name):
+ """Record changed options."""
+ self.changed_options.append(name)
+
+
+# ============================== TupleOptionField ==============================
+
+
+class TestTupleOptionField:
+ """Tests for TupleOptionField."""
+
+ def test_get_default(self):
+ """Getting the default value should return the initial tuple."""
+ c = _MiniContainer()
+ assert c.my_tuple.get() == (10, 20)
+
+ def test_get_optional_default_returns_exact_value(self):
+ """A missing field returns the supplied default before normalization."""
+ c = _MiniContainer()
+ default = [30, 40]
+ assert c.my_tuple.get(default) is default
+ assert c.my_tuple.get() == (30, 40)
+
+ def test_get_optional_default_does_not_replace_set_value(self):
+ """An explicitly set value takes precedence over a later default."""
+ c = _MiniContainer()
+ c.my_tuple.set((50, 60))
+ assert c.my_tuple.get((1, 2)) == (50, 60)
+
+ def test_get_none_does_not_initialize(self):
+ """None is a non-persisting fallback and leaves the field uninitialized."""
+ c = _MiniContainer()
+ assert c.my_tuple.get(None) == (10, 20)
+ assert not c.is_option_initialized("my_tuple")
+
+ def test_context_restores_uninitialized_state(self):
+ """A temporary override must not persist initialization state."""
+ c = _MiniContainer()
+
+ with c.my_tuple.context((30, 40)):
+ assert c.my_tuple.get() == (30, 40)
+ assert c.is_option_initialized("my_tuple")
+
+ assert c.my_tuple.get() == (10, 20)
+ assert not c.is_option_initialized("my_tuple")
+
+ def test_context_restores_state_after_exception(self):
+ """Context restoration also applies when the body raises."""
+ c = _MiniContainer()
+
+ with pytest.raises(RuntimeError, match="stop"):
+ with c.my_tuple.context((30, 40)):
+ raise RuntimeError("stop")
+
+ assert c.my_tuple.get() == (10, 20)
+ assert not c.is_option_initialized("my_tuple")
+
+ def test_set_tuple(self):
+ """Setting a new tuple value should update the stored value."""
+ c = _MiniContainer()
+ c.my_tuple.set((100, 200))
+ assert c.my_tuple.get() == (100, 200)
+
+ def test_set_list_converts_to_tuple(self):
+ """Setting a list should convert it to a tuple and store it correctly."""
+ c = _MiniContainer()
+ c.my_tuple.set([5, 6])
+ assert c.my_tuple.get() == (5, 6)
+ assert isinstance(c.my_tuple.get(), tuple)
+
+ def test_set_none(self):
+ """Setting None should store None without error."""
+ c = _MiniContainer()
+ c.my_tuple.set(None)
+ assert c.my_tuple.get() is None
+
+ def test_set_invalid_type_raises(self):
+ """Setting a non-iterable or non-list/tuple should raise a ValueError."""
+ c = _MiniContainer()
+ with pytest.raises(ValueError, match="expected tuple"):
+ c.my_tuple.set("bad")
+
+ def test_set_invalid_int_raises(self):
+ """Setting an integer should raise a ValueError since it's not iterable."""
+ c = _MiniContainer()
+ with pytest.raises(ValueError, match="expected tuple"):
+ c.my_tuple.set(42)
+
+
+# ============================== FontOptionField ===============================
+
+
+class TestFontOptionField:
+ """Tests for FontOptionField."""
+
+ def test_get_default(self):
+ """Getting the default value should return the initial font tuple."""
+ c = _MiniContainer()
+ assert c.my_font.get() == ("Arial", 12, False)
+
+ def test_set_tuple(self):
+ """Setting a new font tuple should update the stored value."""
+ c = _MiniContainer()
+ c.my_font.set(("Courier", 10, True))
+ assert c.my_font.get() == ("Courier", 10, True)
+
+ def test_set_list_converts_to_tuple(self):
+ """Setting a list should convert it to a tuple and store it correctly."""
+ c = _MiniContainer()
+ c.my_font.set(["Mono", 14, False])
+ result = c.my_font.get()
+ assert result == ("Mono", 14, False)
+ assert isinstance(result, tuple)
+
+ def test_set_none(self):
+ """Setting None should store None without error."""
+ c = _MiniContainer()
+ c.my_font.set(None)
+ assert c.my_font.get() is None
+
+ def test_set_invalid_length_raises(self):
+ """Setting a tuple/list of incorrect length should raise a ValueError."""
+ c = _MiniContainer()
+ with pytest.raises(ValueError, match="expected.*family.*size.*bold"):
+ c.my_font.set(("one", "two"))
+
+ def test_set_invalid_first_element_raises(self):
+ """Setting a non-string first element should raise a ValueError."""
+ c = _MiniContainer()
+ with pytest.raises(ValueError, match="expected.*family.*size.*bold"):
+ c.my_font.set((123, 12, False))
+
+ def test_set_invalid_type_raises(self):
+ """Setting a non-iterable or non-list/tuple should raise a ValueError."""
+ c = _MiniContainer()
+ with pytest.raises(ValueError, match="expected.*family.*size.*bold"):
+ c.my_font.set("bad")
+
+ def test_get_font_builds_qfont(self):
+ """get_font returns a QFont matching the stored specification."""
+ from qtpy.QtWidgets import (
+ QApplication, # pylint: disable=import-outside-toplevel
+ )
+
+ _app = QApplication.instance() or QApplication([])
+ c = _MiniContainer()
+ c.my_font.set(["Courier New", 10, True])
+ font = c.my_font.get_font()
+ assert font.family() == "Courier New"
+ assert font.pointSize() == 10
+ assert font.bold() is True
+
+
+# ======================== ConfigPathOptionField ===============================
+
+
+class TestConfigPathOptionField:
+ """Tests for ConfigPathOptionField."""
+
+ def test_resolves_basename_and_roundtrips_raw_value(self):
+ """ConfigPathOptionField resolves basenames and round-trips raw values."""
+ container = _MiniContainer()
+ field = ConfigPathOptionField(
+ container, "traceback_log_path", ".SigimaX_tb.log"
+ )
+
+ resolved = field.get()
+ assert osp.basename(resolved) == ".SigimaX_tb.log"
+ assert osp.isabs(resolved)
+
+ # Storage accessors expose the bare basename (no path resolution).
+ assert field.to_storage() == ".SigimaX_tb.log"
+ field.from_storage(".Other.log")
+ assert field.to_storage() == ".Other.log"
+ assert osp.basename(field.get()) == ".Other.log"
+
+ # A full path (not a bare basename) is rejected on get().
+ field.from_storage(osp.join("sub", "dir", "file.log"))
+ with pytest.raises(ValueError):
+ field.get()
+
+
+# ======================== WorkingDirOptionField ===============================
+
+
+class TestWorkingDirOptionField:
+ """Tests for WorkingDirOptionField."""
+
+ def test_validates_directories_and_tolerates_missing_ones(self, tmp_path):
+ """WorkingDirOptionField validates directories and tolerates missing ones."""
+ container = _MiniContainer()
+ field = WorkingDirOptionField(container, "base_dir", "")
+
+ # Setting an existing directory stores it and get() returns it.
+ field.set(str(tmp_path))
+ assert field.get() == str(tmp_path)
+
+ # Setting a file path stores its parent directory.
+ a_file = tmp_path / "data.txt"
+ a_file.write_text("x", encoding="utf-8")
+ field.set(str(a_file))
+ assert field.get() == str(tmp_path)
+
+ # Setting an invalid directory raises.
+ with pytest.raises(FileNotFoundError):
+ field.set(str(tmp_path / "does_not_exist" / "child"))
+
+ # get() returns "" when the stored directory no longer exists, but the raw
+ # value is preserved.
+ missing = str(tmp_path / "gone")
+ field.from_storage(missing)
+ assert field.get() == ""
+ assert field.to_storage() == missing
+
+
+# ======================== DataSetOptionField ===================================
+
+
+class TestDataSetOptionField:
+ """Tests for DataSetOptionField."""
+
+ def test_falls_back_to_default_and_roundtrips_json(self):
+ """
+ DataSetOptionField falls back to the default instance and round-trips JSON.
+ """
+ container = _MiniContainer()
+ default = _SampleParam()
+ default.value = 7
+ field = DataSetOptionField(container, "sample_param", default_instance=default)
+
+ # Without an explicit value, get() returns the default instance.
+ assert field.get() is default
+ assert field.get_raw() is None
+ assert field.to_json() is None
+
+ # Setting an explicit value takes precedence.
+ param = _SampleParam()
+ param.value = 42
+ field.set(param)
+ assert field.get() is param
+ assert field.get_raw() is param
+
+ # JSON round-trip restores the stored value.
+ json_str = field.to_json()
+ assert json_str is not None
+ field.from_storage(None)
+ field.from_json(json_str)
+ assert field.get().value == 42
+
+ # set_default_instance updates the fallback used when no value is set.
+ field.from_storage(None)
+ new_default = _SampleParam()
+ new_default.value = 99
+ field.set_default_instance(new_default)
+ assert field.get() is new_default
+
+ def test_invalid_json_uses_default(self):
+ """An unresolved DataSet class is discarded and falls back to the default."""
+ container = _MiniContainer()
+ default = _SampleParam()
+ field = DataSetOptionField(container, "sample_param", default_instance=default)
+ field.from_json(
+ '{"class_module": "missing_module", "class_name": "MissingParam"}'
+ )
+
+ assert field.get() is default
+ assert not container.is_option_initialized("sample_param")
+
+
+# ======================== AppOptionsContainer ================================
+
+
+class TestAppOptionsContainer:
+ """Tests for AppOptionsContainer to_dict / from_dict / reset."""
+
+ def test_to_dict_roundtrip(self):
+ """
+ Setting some values and converting to dict should produce a dict that can be
+ loaded back to the same values.
+ """
+ c = _MiniContainer()
+ c.my_str.set("world")
+ c.my_tuple.set((1, 2))
+ d = c.to_dict()
+ assert d["my_str"] == "world"
+ assert d["my_tuple"] == (1, 2)
+
+ c2 = _MiniContainer()
+ c2.from_dict(d)
+ assert c2.my_str.get() == "world"
+ assert c2.my_tuple.get() == (1, 2)
+
+ def test_from_dict_ignores_unknown_keys(self):
+ """
+ Providing unknown keys in from_dict should not raise an error and should ignore
+ them.
+ """
+ c = _MiniContainer()
+ c.from_dict({"unknown_key": 999, "my_str": "ok"})
+ assert c.my_str.get() == "ok"
+
+ def test_from_dict_marks_option_initialized(self):
+ """Loaded values take precedence over later optional defaults."""
+ c = _MiniContainer()
+ c.from_dict({"my_str": "loaded"})
+ assert c.my_str.get("fallback") == "loaded"
+
+ def test_option_changed_hook_tracks_set_and_context_restore(self):
+ """Option changes and context restoration call the container hook."""
+ c = _MiniContainer()
+ c.my_str.set("set")
+ with c.my_str.context("temporary"):
+ pass
+ assert c.changed_options == ["my_str", "my_str", "my_str"]
+
+ def test_generate_rst_doc_uses_sigimax_option_api(self):
+ """RST generation reads SigimaX options without changing their state."""
+ c = _MiniContainer()
+
+ rst = c.generate_rst_doc()
+
+ assert "``my_str``" in rst
+ assert "``'hello'``" in rst
+ assert c.changed_options == []
+ assert not c.is_option_initialized("my_str")
+
+ def test_from_dict_invalid_value_warning(self, capsys):
+ """Providing an invalid value in from_dict should produce a warning."""
+ c = _MiniContainer()
+ c.from_dict({"my_enum": "invalid_choice"})
+ captured = capsys.readouterr()
+ assert "Warning" in captured.out or "invalid" in captured.out.lower()
+
+ def test_list_options(self):
+ """list_options should return the names of all defined options."""
+ c = _MiniContainer()
+ names = c.list_options()
+ assert "my_tuple" in names
+ assert "my_font" in names
+ assert "my_enum" in names
+ assert "my_str" in names
+
+ def test_save_load_roundtrip(self):
+ """Saving and loading should preserve the values."""
+ c = _MiniContainer()
+ c.my_str.set("persisted")
+ c.my_tuple.set((99, 100))
+
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
+ path = tmp.name
+ c.save(path)
+
+ c2 = _MiniContainer()
+ c2.load(path)
+ assert c2.my_str.get() == "persisted"
+ assert c2.my_tuple.get() == (99, 100)
+
+
+# ======================== SigimaXOptions =====================================
+
+
+class TestSigimaXOptions:
+ """Tests for the full SigimaXOptions CONF singleton."""
+
+ def test_list_options_contains_expected(self):
+ """list_options should include all expected option names."""
+ opts = SigimaXOptions()
+ names = opts.list_options()
+ # Check a representative sample of expected options
+ expected = [
+ "app_name",
+ "color_mode",
+ "console_enabled",
+ "window_maximized",
+ "ima_def_colormap",
+ ]
+ for name in expected:
+ assert name in names, f"Expected option '{name}' not in list_options()"
+
+ def test_reset_to_defaults(self):
+ """reset_to_defaults should restore default values for all options."""
+ opts = SigimaXOptions()
+ original = opts.ima_def_colormap.get()
+ opts.ima_def_colormap.set("gray")
+ assert opts.ima_def_colormap.get() == "gray"
+ opts.reset_to_defaults()
+ assert opts.ima_def_colormap.get() == original
+
+
+# ======================== Module-level helpers ===============================
+
+
+class TestModuleHelpers:
+ """Tests for get_old_log_fname, is_frozen, get_mod_source_dir."""
+
+ def test_get_old_log_fname(self):
+ """get_old_log_fname should insert .1 before the extension."""
+ assert get_old_log_fname("app.log") == "app.1.log"
+ assert get_old_log_fname("/path/to/my.log") == "/path/to/my.1.log"
+
+ def test_is_frozen_returns_bool(self):
+ """is_frozen should return a boolean value."""
+ result = is_frozen("sigimax")
+ assert isinstance(result, bool)
+
+ def test_get_mod_source_dir_not_none_in_dev(self):
+ """get_mod_source_dir should return a directory path in development installs."""
+ # In a development install, get_mod_source_dir should return a directory
+ result = get_mod_source_dir()
+ # Could be None in frozen builds, but in dev it should not be
+ if not hasattr(sys, "_MEIPASS"):
+ assert result is not None
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/config/test_options_subclass.py b/sigimax/tests/config/test_options_subclass.py
new file mode 100644
index 0000000..d9eede4
--- /dev/null
+++ b/sigimax/tests/config/test_options_subclass.py
@@ -0,0 +1,45 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Quick test of SigimaX options subclassing."""
+
+# guitest: show
+
+import pytest
+
+from sigimax.config import SigimaXOptions, TypedOptionField
+
+
+class MyAppOptions(SigimaXOptions):
+ """
+ Docstring for MyAppOptions
+ """
+
+ APP_NAME = "MyApp"
+
+ def __init__(self):
+ super().__init__()
+ self.rpc_enabled = TypedOptionField(
+ self,
+ "rpc_enabled",
+ default=True,
+ expected_type=bool,
+ description="RPC server",
+ )
+ self.rpc_port = TypedOptionField(
+ self, "rpc_port", default=8080, expected_type=int, description="RPC port"
+ )
+
+
+@pytest.mark.unit
+def test_options_subclass():
+ """Test that SigimaXOptions can be subclassed with custom options."""
+ o = MyAppOptions()
+ assert len(o.list_options()) > 0
+ assert o.rpc_enabled.get() is True
+ assert o.rpc_port.get() == 8080
+ # Inherited option from SigimaXOptions must be accessible
+ assert o.color_mode.get() is not None
+
+
+if __name__ == "__main__":
+ test_options_subclass()
diff --git a/sigimax/tests/conftest.py b/sigimax/tests/conftest.py
new file mode 100644
index 0000000..15a2e8e
--- /dev/null
+++ b/sigimax/tests/conftest.py
@@ -0,0 +1,126 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX pytest configuration
+----------------------------
+
+This file contains the configuration for running pytest in SigimaX. It is
+executed before running any tests.
+"""
+
+import os
+import os.path as osp
+
+import guidata
+import h5py
+import numpy
+import plotpy
+import pytest
+import qtpy
+import qwt
+import scipy
+import sigima
+import skimage
+from guidata.config import ValidationMode, set_validation_mode
+from guidata.utils.gitreport import format_git_info_for_pytest, get_git_info_for_modules
+from sigima.tests import helpers
+
+import sigimax
+from sigimax.env import execenv
+
+# Set validation mode to STRICT for all tests
+set_validation_mode(ValidationMode.STRICT)
+
+# Turn on unattended mode for executing tests without user interaction
+execenv.unattended = True
+execenv.verbose = "quiet"
+
+INITIAL_CWD = os.getcwd()
+
+
+def pytest_addoption(parser):
+ """Add custom command line options to pytest."""
+ parser.addoption(
+ "--show-windows",
+ action="store_true",
+ default=False,
+ help="Display Qt windows during tests (disables QT_QPA_PLATFORM=offscreen)",
+ )
+
+
+def pytest_report_header(config): # pylint: disable=unused-argument
+ """Add additional information to the pytest report header."""
+ qtbindings_version = qtpy.PYSIDE_VERSION
+ if qtbindings_version is None:
+ qtbindings_version = qtpy.PYQT_VERSION
+ infolist = [
+ f" sigimax {sigimax.__version__},",
+ f" sigima {sigima.__version__},",
+ f" guidata {guidata.__version__}, PlotPy {plotpy.__version__}",
+ f" PythonQwt {qwt.__version__}, "
+ f"{qtpy.API_NAME} {qtbindings_version} [Qt version: {qtpy.QT_VERSION}]",
+ f" NumPy {numpy.__version__}, SciPy {scipy.__version__}, "
+ f"h5py {h5py.__version__}, scikit-image {skimage.__version__}",
+ ]
+ envlist = []
+ for vname in ("PYTHONPATH", "DEBUG", "QT_API", "QT_QPA_PLATFORM"):
+ value = os.environ.get(vname, "")
+ if value:
+ if vname == "PYTHONPATH":
+ pathlist = value.split(os.pathsep)
+ envlist.append(f" {vname}:")
+ envlist.extend(f" {p}" for p in pathlist if p)
+ else:
+ envlist.append(f" {vname}: {value}")
+ if envlist:
+ infolist.append("Environment variables:")
+ infolist.extend(envlist)
+ infolist.append("Test paths:")
+ for test_path in helpers.get_test_paths():
+ test_path = osp.abspath(test_path)
+ infolist.append(f" {test_path}")
+
+ # Git information for all modules using the new gitreport module
+ modules_config = [
+ ("SigimaX", sigimax, "."), # SigimaX uses current directory
+ ("guidata", guidata, None),
+ ("PlotPy", plotpy, None),
+ ("Sigima", sigima, None),
+ ]
+ git_repos = get_git_info_for_modules(modules_config)
+ git_info_lines = format_git_info_for_pytest(git_repos, "SigimaX")
+ if git_info_lines:
+ infolist.extend(git_info_lines)
+
+ return infolist
+
+
+def pytest_configure(config):
+ """Add custom markers to pytest."""
+ if config.option.durations is None:
+ config.option.durations = 20 # Default to showing 20 slowest tests
+ config.addinivalue_line(
+ "markers",
+ "validation: mark a test as a validation test (ground truth or analytical)",
+ )
+ config.addinivalue_line(
+ "markers",
+ "unit: pure logic test, no Qt application context needed",
+ )
+ config.addinivalue_line(
+ "markers",
+ "app: requires full application context (SGMXMainWindow)",
+ )
+ config.addinivalue_line(
+ "markers",
+ "gui: requires visible Qt window (use --show-windows)",
+ )
+ if not config.getoption("--show-windows"):
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+
+@pytest.fixture(autouse=True)
+def reset_cwd(request): # pylint: disable=unused-argument
+ """Reset the current working directory to the initial one after each test."""
+ yield
+ os.chdir(INITIAL_CWD)
diff --git a/sigimax/tests/hdf5/__init__.py b/sigimax/tests/hdf5/__init__.py
new file mode 100644
index 0000000..792d600
--- /dev/null
+++ b/sigimax/tests/hdf5/__init__.py
@@ -0,0 +1 @@
+#
diff --git a/sigimax/tests/hdf5/_h5browser_memoryleak.py b/sigimax/tests/hdf5/_h5browser_memoryleak.py
new file mode 100644
index 0000000..0ae9d49
--- /dev/null
+++ b/sigimax/tests/hdf5/_h5browser_memoryleak.py
@@ -0,0 +1,55 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+HDF5 browser unit tests 2
+-------------------------
+
+Testing for memory leak
+"""
+
+# guitest: show,skip
+
+import os
+import time
+
+import numpy as np
+import psutil
+from guidata.qthelpers import qt_app_context
+from sigima.viz import view_curves
+
+from sigimax.env import execenv
+from sigimax.tests import helpers
+from sigimax.widgets.h5browser import H5BrowserDialog
+
+
+def test_memoryleak(fname, iterations=20):
+ """Memory leak test"""
+ with qt_app_context():
+ proc = psutil.Process(os.getpid())
+ fname = helpers.get_test_fnames(fname)[0]
+ dlg = H5BrowserDialog(None)
+ memlist = []
+ for i in range(iterations):
+ t0 = time.time()
+ dlg.open_file(fname)
+ memdata = proc.memory_info().vms / 1024**2
+ memlist.append(memdata)
+ execenv.print(i + 1, ":", memdata, "MB")
+ dlg.browser.tree.select_all(True)
+ dlg.browser.tree.toggle_all(True)
+ execenv.print(i + 1, ":", proc.memory_info().vms / 1024**2, "MB")
+ dlg.show()
+ dlg.accept()
+ dlg.close()
+ execenv.print(i + 1, ":", proc.memory_info().vms / 1024**2, "MB")
+ dlg.cleanup()
+ execenv.print(i + 1, ":", f"{(time.time() - t0):.1f} s")
+ view_curves(
+ np.array(memlist),
+ title="Memory leak test for HDF5 browser dialog",
+ ylabel="Memory (MB)",
+ )
+
+
+if __name__ == "__main__":
+ test_memoryleak("scenario*.h5")
diff --git a/sigimax/tests/hdf5/test_h5_common_collect_attributes.py b/sigimax/tests/hdf5/test_h5_common_collect_attributes.py
new file mode 100644
index 0000000..ab392b9
--- /dev/null
+++ b/sigimax/tests/hdf5/test_h5_common_collect_attributes.py
@@ -0,0 +1,100 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+HDF5 reference-attribute unit tests
+------------------------------------
+
+Headless regression tests for :meth:`sigimax.h5.common.BaseNode.collect_attributes`.
+
+Files converted from HDF4 with ``h4toh5convert`` carry HDF5 object/region
+*reference* attributes (e.g. ``DIMENSION_LIST`` / ``REFERENCE_LIST``). h5py
+exposes those as :class:`h5py.h5r.Reference` values, which are **not** picklable
+(``TypeError: no default __reduce__ due to non-trivial __cinit__``). Copying them
+into an object's metadata used to crash the first computation, because DataLab
+pickles the object to run it in a worker process.
+"""
+
+from __future__ import annotations
+
+import pickle
+import uuid
+
+import h5py
+import numpy as np
+import pytest
+
+from sigimax.h5.common import BaseNode
+
+pytestmark = pytest.mark.unit
+
+
+class _LeafNode(BaseNode):
+ """Minimal concrete node exposing :meth:`collect_attributes`."""
+
+
+def _new_memory_file() -> h5py.File:
+ """Return a fresh in-memory HDF5 file with a unique name."""
+ return h5py.File(f"{uuid.uuid4()}.h5", "w", driver="core", backing_store=False)
+
+
+def test_collect_attributes_keeps_serialisable_values() -> None:
+ """Numeric, boolean and string attributes are copied to metadata."""
+ h5file = _new_memory_file()
+ try:
+ dset = h5file.create_dataset("data", data=np.zeros((4, 4)))
+ dset.attrs["gain"] = 2.5
+ dset.attrs["count"] = np.int32(7)
+ dset.attrs["enabled"] = True
+ dset.attrs["label"] = b"detector"
+ dset.attrs["profile"] = np.array([1.0, 2.0, 3.0])
+ dset.attrs["tags"] = np.array([b"a", b"b"])
+
+ node = _LeafNode(h5file, "data")
+ node.collect_attributes()
+
+ assert node.metadata["gain"] == 2.5
+ assert node.metadata["count"] == 7
+ assert node.metadata["enabled"]
+ assert node.metadata["label"] == "detector"
+ assert np.array_equal(node.metadata["profile"], [1.0, 2.0, 3.0])
+ assert list(node.metadata["tags"]) == [b"a", b"b"]
+ # The whole metadata mapping must stay picklable.
+ pickle.dumps(node.metadata)
+ finally:
+ h5file.close()
+
+
+def test_collect_attributes_skips_reference_attributes() -> None:
+ """HDF5 reference attributes are dropped (they are not picklable)."""
+ ref_dtype = h5py.special_dtype(ref=h5py.Reference)
+ h5file = _new_memory_file()
+ try:
+ h5file.create_dataset("data", data=np.zeros((4, 4)))
+ h5file.create_dataset("scale0", data=np.arange(4))
+ h5file.create_dataset("scale1", data=np.arange(4))
+ # Sanity check: a raw HDF5 reference is indeed not picklable.
+ with pytest.raises(TypeError):
+ pickle.dumps(h5file["scale0"].ref)
+ # Array of references, as produced by h4toh5convert (DIMENSION_LIST).
+ refs = np.array([h5file["scale0"].ref, h5file["scale1"].ref], dtype=ref_dtype)
+ h5file["data"].attrs.create("DIMENSION_LIST", refs)
+ # Scalar reference attribute.
+ h5file["data"].attrs["REFERENCE"] = h5file["scale0"].ref
+ # A regular attribute alongside the reference ones.
+ h5file["data"].attrs["gain"] = 1.5
+
+ node = _LeafNode(h5file, "data")
+ node.collect_attributes()
+
+ assert "DIMENSION_LIST" not in node.metadata
+ assert "REFERENCE" not in node.metadata
+ assert node.metadata["gain"] == 1.5
+ # The surviving metadata is picklable (the whole point of the fix).
+ pickle.dumps(node.metadata)
+ finally:
+ h5file.close()
+
+
+if __name__ == "__main__":
+ test_collect_attributes_keeps_serialisable_values()
+ test_collect_attributes_skips_reference_attributes()
diff --git a/sigimax/tests/hdf5/test_h5_derived_app.py b/sigimax/tests/hdf5/test_h5_derived_app.py
new file mode 100644
index 0000000..b0bc956
--- /dev/null
+++ b/sigimax/tests/hdf5/test_h5_derived_app.py
@@ -0,0 +1,631 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Derived application with HDF5 workspace serialization test
+-----------------------------------------------------------
+
+This test demonstrates how a derived application can:
+
+1. Connect :data:`SIG_SEND_OBJECTLIST` to populate a simple data model.
+2. Override :meth:`save_h5_workspace` to serialize objects using
+ :class:`guidata.io.HDF5Writer`.
+3. Re-open the saved file with :class:`guidata.io.HDF5Reader` and verify
+ the round-trip.
+
+The data model is intentionally minimal — a plain list of
+:class:`SignalObj` / :class:`ImageObj` — to serve as a starting point for
+downstream applications.
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import os.path as osp
+import tempfile
+
+import h5py
+import numpy as np
+import pytest
+from guidata.io import HDF5Reader, HDF5Writer
+from plotpy.constants import PlotType
+from sigima import ImageObj, SignalObj
+
+from sigimax.config import CONF as Conf
+from sigimax.env import execenv
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.tests import helpers
+from sigimax.utils import qthelpers as qth
+from sigimax.widgets.plotdock import DockablePlotWidget
+
+# Workspace HDF5 key used to store the application version
+_VERSION_ATTR = "app_version"
+
+# Group names inside the workspace file
+_SIGNALS_GROUP = "signals"
+_IMAGES_GROUP = "images"
+
+
+# =============================================================================
+# Minimal data model
+# =============================================================================
+
+
+class SimpleObjectStore:
+ """Minimal object store: two ordered lists (signals + images).
+
+ This is the simplest useful data model for a SigimaX-based application.
+ Downstream projects can replace it with a richer structure (UUID-keyed
+ dict, groups, etc.) without changing the serialization contract.
+ """
+
+ def __init__(self) -> None:
+ self.signals: list[SignalObj] = []
+ self.images: list[ImageObj] = []
+
+ # -- Mutation ----------------------------------------------------------
+
+ def add_objects(self, objects: list[SignalObj | ImageObj]) -> None:
+ """Dispatch a list of mixed objects into the appropriate sub-list.
+
+ Args:
+ objects: list of :class:`SignalObj` and/or :class:`ImageObj`
+ """
+ for obj in objects:
+ if isinstance(obj, SignalObj):
+ self.signals.append(obj)
+ elif isinstance(obj, ImageObj):
+ self.images.append(obj)
+
+ def clear(self) -> None:
+ """Remove all objects."""
+ self.signals.clear()
+ self.images.clear()
+
+ # -- Query -------------------------------------------------------------
+
+ @property
+ def count(self) -> int:
+ """Total number of objects."""
+ return len(self.signals) + len(self.images)
+
+ # -- Serialization -----------------------------------------------------
+
+ def serialize(self, writer: HDF5Writer) -> None:
+ """Write all objects into the currently open HDF5 writer.
+
+ Layout::
+
+ /signals/
+ 000/ ← SignalObj.serialize()
+ 001/
+ /images/
+ 000/ ← ImageObj.serialize()
+
+ Args:
+ writer: an open :class:`guidata.io.HDF5Writer`
+ """
+ with writer.group(_SIGNALS_GROUP):
+ for idx, sig in enumerate(self.signals):
+ with writer.group(f"{idx:03d}"):
+ sig.serialize(writer)
+ with writer.group(_IMAGES_GROUP):
+ for idx, ima in enumerate(self.images):
+ with writer.group(f"{idx:03d}"):
+ ima.serialize(writer)
+
+ def deserialize(self, reader: HDF5Reader) -> None:
+ """Read all objects from the currently open HDF5 reader.
+
+ Clears the store before loading.
+
+ Args:
+ reader: an open :class:`guidata.io.HDF5Reader`
+ """
+ self.clear()
+
+ # Signals
+ if _SIGNALS_GROUP in reader.h5:
+ with reader.group(_SIGNALS_GROUP):
+ idx = 0
+ while True:
+ group_name = f"{idx:03d}"
+ current = reader.h5["/" + "/".join(reader.option)]
+ if group_name not in current:
+ break
+ with reader.group(group_name):
+ obj = SignalObj()
+ obj.deserialize(reader)
+ self.signals.append(obj)
+ idx += 1
+
+ # Images
+ if _IMAGES_GROUP in reader.h5:
+ with reader.group(_IMAGES_GROUP):
+ idx = 0
+ while True:
+ group_name = f"{idx:03d}"
+ current = reader.h5["/" + "/".join(reader.option)]
+ if group_name not in current:
+ break
+ with reader.group(group_name):
+ obj = ImageObj()
+ obj.deserialize(reader)
+ self.images.append(obj)
+ idx += 1
+
+
+# =============================================================================
+# Derived main window
+# =============================================================================
+
+
+class DerivedAppWindow(SGMXMainWindow):
+ """Example derived main window with a data model and workspace save/load.
+
+ Demonstrates:
+ - Connecting ``SIG_SEND_OBJECTLIST`` to populate the data model
+ - Overriding ``save_h5_workspace`` using ``guidata.io.HDF5Writer``
+ - A helper ``load_h5_workspace`` method using ``guidata.io.HDF5Reader``
+ """
+
+ def __init__(
+ self,
+ console: bool | None = None,
+ hide_on_close: bool = False,
+ ) -> None:
+ # Configure global Conf before super().__init__()
+ Conf.app_name.set("DerivedH5App")
+ Conf.app_version.set("0.1.0")
+
+ self.curve_dock = None
+
+ super().__init__(console=console, hide_on_close=hide_on_close)
+
+ # --- Wire the signal emitted by browse_h5_files / import_all_from_h5_file ---
+ self.SIG_SEND_OBJECTLIST.connect(self._on_objects_received)
+
+ def _setup_docks(self) -> None:
+ """Add a curve dock for visual feedback."""
+ self.curve_dock = DockablePlotWidget(self, PlotType.CURVE)
+ self._add_dockwidget(self.curve_dock, "Preview", name="preview")
+
+ def _before_setup(self, console: bool) -> None:
+ """Create the data model before generic setup hooks may use it."""
+ super()._before_setup(console)
+ self.object_store = SimpleObjectStore()
+
+ # ------------------------------------------------------------------
+ # Signal handler
+ # ------------------------------------------------------------------
+
+ def _on_objects_received(self, objects: list[SignalObj | ImageObj]) -> None:
+ """Slot connected to :data:`SIG_SEND_OBJECTLIST`.
+
+ Stores objects in the data model and updates the status bar.
+ """
+ self.object_store.add_objects(objects)
+ execenv.print(
+ f"Object store now contains {self.object_store.count} object(s) "
+ f"({len(self.object_store.signals)} signals, "
+ f"{len(self.object_store.images)} images)"
+ )
+
+ # ------------------------------------------------------------------
+ # Workspace serialization (override)
+ # ------------------------------------------------------------------
+
+ def save_h5_workspace(self, filename: str) -> None:
+ """Save workspace to HDF5 using :class:`guidata.io.HDF5Writer`.
+
+ Overrides the base no-op to actually serialize all objects held in
+ :attr:`object_store`.
+
+ Args:
+ filename: HDF5 filename to save to
+ """
+ filename = self._check_h5file(filename, "save")
+ with HDF5Writer(filename) as writer:
+ writer.h5.attrs[_VERSION_ATTR] = Conf.app_version.get()
+ self.object_store.serialize(writer)
+ self.set_modified(False)
+ execenv.print(
+ f"Workspace saved to '{filename}' ({self.object_store.count} object(s))"
+ )
+
+ def load_h5_workspace(self, filename: str) -> None:
+ """Load workspace from an HDF5 file previously saved by this app.
+
+ Args:
+ filename: HDF5 filename to load from
+ """
+ filename = self._check_h5file(filename, "load")
+ with HDF5Reader(filename) as reader:
+ self.object_store.deserialize(reader)
+ self.set_modified(False)
+ execenv.print(
+ f"Workspace loaded from '{filename}' ({self.object_store.count} object(s))"
+ )
+
+ def import_dataset_from_file(
+ self,
+ filename: str,
+ dsetname: str | None,
+ import_all: bool | None,
+ reset_all: bool,
+ ) -> None:
+ """Import a specific dataset from a generic HDF5 file.
+
+ Reads a raw HDF5 dataset by name and wraps it as a
+ :class:`SignalObj` (1-D) or :class:`ImageObj` (2-D).
+
+ Args:
+ filename: Path to the HDF5 file (already validated)
+ dsetname: Dataset name to import, or ``None`` to import all
+ import_all: If ``True``, import all datasets without browsing
+ reset_all: If ``True``, clear workspace before importing
+ """
+ if reset_all:
+ self.object_store.clear()
+
+ objects: list[SignalObj | ImageObj] = []
+ with h5py.File(filename, "r") as h5:
+ if dsetname is not None:
+ names = [dsetname]
+ else:
+ names = [k for k, v in h5.items() if isinstance(v, h5py.Dataset)]
+ for name in names:
+ if name not in h5:
+ execenv.print(f"Dataset '{name}' not found in '{filename}'")
+ continue
+ node = h5[name]
+ if not isinstance(node, h5py.Dataset):
+ continue
+ data = node[()]
+ if data.ndim == 1:
+ obj = SignalObj()
+ obj.set_xydata(
+ np.arange(len(data), dtype=float), data.astype(float)
+ )
+ obj.title = name
+ objects.append(obj)
+ elif data.ndim == 2:
+ obj = ImageObj()
+ obj.data = data
+ obj.title = name
+ objects.append(obj)
+
+ if objects:
+ self.SIG_SEND_OBJECTLIST.emit(objects)
+ self.set_modified(True)
+ execenv.print(f"Imported {len(objects)} dataset(s) from '{filename}'")
+
+
+# =============================================================================
+# Test helpers
+# =============================================================================
+
+
+def _create_test_signal(index: int) -> SignalObj:
+ """Create a simple test signal."""
+ x = np.linspace(0, 10, 200)
+ y = np.sin(x * (index + 1)) + 0.05 * np.random.randn(len(x))
+ obj = SignalObj()
+ obj.set_xydata(x, y)
+ obj.title = f"Test signal #{index}"
+ return obj
+
+
+def _create_test_image(index: int) -> ImageObj:
+ """Create a simple test image."""
+ data = np.random.randint(0, 255, (64, 64), dtype=np.uint8)
+ obj = ImageObj()
+ obj.data = data
+ obj.title = f"Test image #{index}"
+ return obj
+
+
+def _create_h5_with_datasets(path: str) -> None:
+ """Create an HDF5 file with raw named datasets for dataset-import tests.
+
+ Layout::
+
+ /sine (1-D float64, 200 points)
+ /cosine (1-D float64, 200 points)
+ /checkerboard (2-D uint8, 64×64)
+ """
+ x = np.linspace(0, 2 * np.pi, 200)
+ with h5py.File(path, "w") as h5:
+ h5.create_dataset("sine", data=np.sin(x))
+ h5.create_dataset("cosine", data=np.cos(x))
+ h5.create_dataset(
+ "checkerboard",
+ data=np.indices((64, 64)).sum(axis=0).astype(np.uint8) % 2 * 255,
+ )
+
+
+# =============================================================================
+# Tests
+# =============================================================================
+
+
+@pytest.mark.app
+def test_base_window_cannot_acknowledge_workspace_save() -> None:
+ """Test that base save keeps the workspace marked as modified."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = SGMXMainWindow(console=False)
+ win.set_modified(True)
+ assert not win._is_save_enabled() # pylint: disable=protected-access
+ with pytest.raises(NotImplementedError, match="save_h5_workspace"):
+ win.save_h5_workspace("workspace.h5")
+ assert win.is_modified()
+ win.close()
+
+
+@pytest.mark.unit
+def test_object_store_serialize_roundtrip() -> None:
+ """Test SimpleObjectStore serialize/deserialize without GUI."""
+ store = SimpleObjectStore()
+ store.add_objects(
+ [
+ _create_test_signal(0),
+ _create_test_signal(1),
+ _create_test_image(0),
+ ]
+ )
+ assert store.count == 3
+ assert len(store.signals) == 2
+ assert len(store.images) == 1
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = osp.join(tmpdir, "roundtrip_test.h5")
+
+ # Write
+ with HDF5Writer(path) as writer:
+ store.serialize(writer)
+
+ # Read into a fresh store
+ store2 = SimpleObjectStore()
+ with HDF5Reader(path) as reader:
+ store2.deserialize(reader)
+
+ assert store2.count == 3
+ assert len(store2.signals) == 2
+ assert len(store2.images) == 1
+
+ # Verify data integrity
+ np.testing.assert_array_almost_equal(
+ store.signals[0].xydata, store2.signals[0].xydata
+ )
+ np.testing.assert_array_almost_equal(
+ store.signals[1].xydata, store2.signals[1].xydata
+ )
+ np.testing.assert_array_equal(store.images[0].data, store2.images[0].data)
+
+ # Verify titles
+ assert store2.signals[0].title == "Test signal #0"
+ assert store2.signals[1].title == "Test signal #1"
+ assert store2.images[0].title == "Test image #0"
+
+ execenv.print("Object store round-trip test passed.")
+
+
+@pytest.mark.app
+def test_derived_app_h5_workspace() -> None:
+ """Test derived app: import → save → reload round-trip."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = DerivedAppWindow(console=False)
+ win.resize(1200, 700)
+ win.show()
+
+ # Populate the data model via the signal
+ test_objects = [
+ _create_test_signal(0),
+ _create_test_signal(1),
+ _create_test_signal(2),
+ _create_test_image(0),
+ _create_test_image(1),
+ ]
+ win.SIG_SEND_OBJECTLIST.emit(test_objects)
+
+ assert win.object_store.count == 5
+ assert len(win.object_store.signals) == 3
+ assert len(win.object_store.images) == 2
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = osp.join(tmpdir, "workspace_test.h5")
+
+ # Save via the overridden method (goes through save_to_h5_file flow)
+ win.save_h5_workspace(path)
+ assert osp.isfile(path)
+ assert not win.is_modified()
+
+ # Create a second window and load the workspace
+ win2 = DerivedAppWindow(console=False)
+ win2.resize(1200, 700)
+ win2.show()
+
+ assert win2.object_store.count == 0
+ win2.load_h5_workspace(path)
+
+ assert win2.object_store.count == 5
+ assert len(win2.object_store.signals) == 3
+ assert len(win2.object_store.images) == 2
+
+ # Verify data
+ np.testing.assert_array_almost_equal(
+ win.object_store.signals[0].xydata,
+ win2.object_store.signals[0].xydata,
+ )
+ np.testing.assert_array_equal(
+ win.object_store.images[0].data,
+ win2.object_store.images[0].data,
+ )
+
+ # Verify titles survived the round-trip
+ for i in range(3):
+ assert win2.object_store.signals[i].title == f"Test signal #{i}", (
+ f"Signal title mismatch at index {i}"
+ )
+ for i in range(2):
+ assert win2.object_store.images[i].title == f"Test image #{i}", (
+ f"Image title mismatch at index {i}"
+ )
+
+ win2.set_modified(False)
+ win2.close()
+
+ win.set_modified(False)
+ win.close()
+
+ execenv.print("Derived app workspace round-trip test passed.")
+
+
+@pytest.mark.app
+def test_derived_app_import_and_save() -> None:
+ """Test importing an HDF5 file and saving the workspace."""
+ fnames = helpers.get_test_fnames("*.h5")
+ if not fnames:
+ execenv.print("No test HDF5 files found, skipping test.")
+ return
+
+ fname = fnames[-1]
+ with qth.sigimax_app_context(exec_loop=False):
+ win = DerivedAppWindow(console=False)
+ win.resize(1200, 700)
+ win.show()
+
+ # Import objects from a real HDF5 file
+ execenv.print(f"Importing HDF5 file: {fname}")
+ win.import_all_from_h5_file(fname)
+
+ initial_count = win.object_store.count
+ execenv.print(f"Imported {initial_count} object(s)")
+
+ if initial_count > 0:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = osp.join(tmpdir, "import_save_test.h5")
+ win.save_h5_workspace(path)
+ assert osp.isfile(path)
+
+ # Reload and verify count matches
+ win2 = DerivedAppWindow(console=False)
+ win2.show()
+ win2.load_h5_workspace(path)
+ assert win2.object_store.count == initial_count
+ win2.set_modified(False)
+ win2.close()
+
+ win.set_modified(False)
+ win.close()
+
+ execenv.print("Import-and-save test passed.")
+
+
+@pytest.mark.app
+def test_import_specific_dataset_and_save() -> None:
+ """Test importing a specific dataset by name and save/load round-trip.
+
+ Exercises :meth:`DerivedAppWindow.import_dataset_from_file` via the
+ ``open_h5_files`` comma-separated syntax (``"file.h5,dataset_name"``).
+ """
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # -- Create an HDF5 file with raw named datasets --
+ src_path = osp.join(tmpdir, "raw_datasets.h5")
+ _create_h5_with_datasets(src_path)
+
+ with qth.sigimax_app_context(exec_loop=False):
+ win = DerivedAppWindow(console=False)
+ win.resize(1200, 700)
+ win.show()
+
+ # -- 1. Import a single dataset by name --
+ win.open_h5_files(
+ h5files=[f"{src_path},sine"],
+ import_all=False,
+ reset_all=False,
+ )
+ assert win.object_store.count == 1, (
+ f"Expected 1 object, got {win.object_store.count}"
+ )
+ assert len(win.object_store.signals) == 1
+ assert win.object_store.signals[0].title == "sine"
+
+ # -- 2. Import another dataset (no reset) --
+ win.open_h5_files(
+ h5files=[f"{src_path},checkerboard"],
+ import_all=False,
+ reset_all=False,
+ )
+ assert win.object_store.count == 2
+ assert len(win.object_store.images) == 1
+ assert win.object_store.images[0].title == "checkerboard"
+
+ # The historical ``filename,dataset`` contract rejects any extra
+ # comma rather than silently changing how the selector is parsed.
+ with pytest.raises(ValueError):
+ win.open_h5_files(
+ h5files=[f"{src_path},sine,extra"],
+ import_all=False,
+ reset_all=False,
+ )
+
+ # -- 3. Import all datasets at once (with reset) --
+ win.open_h5_files(
+ h5files=[src_path],
+ import_all=True,
+ reset_all=True,
+ )
+ # import_all=True triggers import_dataset_from_file with dsetname=None
+ assert win.object_store.count == 3, (
+ f"Expected 3 objects, got {win.object_store.count}"
+ )
+ assert len(win.object_store.signals) == 2 # sine + cosine
+ assert len(win.object_store.images) == 1 # checkerboard
+
+ # -- 4. Save workspace and reload --
+ ws_path = osp.join(tmpdir, "workspace_specific.h5")
+ win.save_h5_workspace(ws_path)
+ assert osp.isfile(ws_path)
+
+ win2 = DerivedAppWindow(console=False)
+ win2.resize(1200, 700)
+ win2.show()
+
+ win2.load_h5_workspace(ws_path)
+ assert win2.object_store.count == 3
+ assert len(win2.object_store.signals) == 2
+ assert len(win2.object_store.images) == 1
+
+ # Verify data integrity for the sine signal
+ np.testing.assert_array_almost_equal(
+ win.object_store.signals[0].y,
+ win2.object_store.signals[0].y,
+ )
+ # Verify image data integrity
+ np.testing.assert_array_equal(
+ win.object_store.images[0].data,
+ win2.object_store.images[0].data,
+ )
+
+ win2.set_modified(False)
+ win2.close()
+ win.set_modified(False)
+ win.close()
+
+ execenv.print("Import-specific-dataset and save/load test passed.")
+
+
+def show_derivated_app() -> None:
+ """Show the derived application window."""
+ with qth.sigimax_app_context(exec_loop=True):
+ win = DerivedAppWindow(console=False)
+ win.show()
+
+
+if __name__ == "__main__":
+ test_object_store_serialize_roundtrip()
+ test_derived_app_h5_workspace()
+ test_derived_app_import_and_save()
+ test_import_specific_dataset_and_save()
+ # show_derivated_app()
diff --git a/sigimax/tests/hdf5/test_h5_utils.py b/sigimax/tests/hdf5/test_h5_utils.py
new file mode 100644
index 0000000..ac8f1c6
--- /dev/null
+++ b/sigimax/tests/hdf5/test_h5_utils.py
@@ -0,0 +1,322 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for HDF5 utility modules
+-------------------------------
+
+Covers:
+- h5/common.py: data_to_xy with various shaped arrays
+- h5/generic.py: safe_decode_bytes, format_text_data
+- h5/utils.py: fix_ldata, fix_ndata, is_single_str_array, is_supported_num_dtype,
+ is_supported_str_dtype, process_scalar_value, process_label, process_xy_values
+"""
+
+from __future__ import annotations
+
+import h5py
+import numpy as np
+import pytest
+
+from sigimax.h5.common import data_to_xy
+from sigimax.h5.generic import format_text_data, safe_decode_bytes
+from sigimax.h5.utils import (
+ fix_ldata,
+ fix_ndata,
+ is_single_str_array,
+ is_supported_num_dtype,
+ is_supported_str_dtype,
+ process_label,
+ process_scalar_value,
+ process_xy_values,
+)
+
+pytestmark = pytest.mark.unit
+
+
+# ======================== data_to_xy =========================================
+
+
+class TestDataToXy:
+ """Tests for data_to_xy conversion."""
+
+ def test_1d_array(self):
+ """1D array should be treated as y values with x as indices."""
+ data = np.array([10, 20, 30])
+ x, y, dx, dy = data_to_xy(data)
+ np.testing.assert_array_equal(x, np.arange(3))
+ np.testing.assert_array_equal(y, data)
+ assert dx is None
+ assert dy is None
+
+ def test_2col_array(self):
+ """2D array with 2 columns should be treated as x and y."""
+ data = np.array([[1, 4], [2, 5], [3, 6]])
+ x, y, dx, dy = data_to_xy(data)
+ np.testing.assert_array_equal(x, [1, 2, 3])
+ np.testing.assert_array_equal(y, [4, 5, 6])
+ assert dx is None
+ assert dy is None
+
+ def test_3col_array(self):
+ """3D array with 3 columns should be treated as x, y, and dy."""
+ # rows > cols triggers transpose: 5×3 → 3×5
+ data = np.array(
+ [[1, 4, 0.1], [2, 5, 0.2], [3, 6, 0.3], [4, 7, 0.4], [5, 8, 0.5]]
+ )
+ x, y, dx, dy = data_to_xy(data)
+ np.testing.assert_array_equal(x, [1, 2, 3, 4, 5])
+ np.testing.assert_array_equal(y, [4, 5, 6, 7, 8])
+ assert dx is None
+ np.testing.assert_array_almost_equal(dy, [0.1, 0.2, 0.3, 0.4, 0.5])
+
+ def test_4col_array(self):
+ """4D array with 4 columns should be treated as x, y, dx, and dy."""
+ # rows > cols triggers transpose: 5×4 → 4×5
+ data = np.array(
+ [
+ [1, 4, 0.1, 0.4],
+ [2, 5, 0.2, 0.5],
+ [3, 6, 0.3, 0.6],
+ [4, 7, 0.4, 0.7],
+ [5, 8, 0.5, 0.8],
+ ]
+ )
+ x, y, dx, dy = data_to_xy(data)
+ np.testing.assert_array_equal(x, [1, 2, 3, 4, 5])
+ np.testing.assert_array_equal(y, [4, 5, 6, 7, 8])
+ np.testing.assert_array_almost_equal(dx, [0.1, 0.2, 0.3, 0.4, 0.5])
+ np.testing.assert_array_almost_equal(dy, [0.4, 0.5, 0.6, 0.7, 0.8])
+
+ def test_2row_array_transposed(self):
+ """2 rows × many cols should be transposed."""
+ data = np.array([[1, 2, 3, 4, 5], [10, 20, 30, 40, 50]])
+ x, y, _dx, _dy = data_to_xy(data)
+ np.testing.assert_array_equal(x, [1, 2, 3, 4, 5])
+ np.testing.assert_array_equal(y, [10, 20, 30, 40, 50])
+
+ def test_invalid_shape_raises(self):
+ """Arrays with unsupported shapes should raise an error."""
+ data = np.ones((5, 5, 5))
+ with pytest.raises((ValueError, IndexError)):
+ data_to_xy(data)
+
+
+# ======================== safe_decode_bytes ==================================
+
+
+class TestSafeDecodeBytes:
+ """Tests for safe_decode_bytes."""
+
+ def test_str_passthrough(self):
+ """String input should be returned unchanged."""
+ assert safe_decode_bytes("hello") == "hello"
+
+ def test_bytes_utf8(self):
+ """UTF-8 encoded bytes should be decoded to a string."""
+ assert safe_decode_bytes(b"hello") == "hello"
+
+ def test_bytes_latin1(self):
+ """Bytes that are not valid UTF-8 should be decoded with latin1 fallback."""
+ result = safe_decode_bytes("café".encode("latin1"))
+ assert "caf" in result
+
+ def test_none_returns_str(self):
+ """None input should be converted to an empty string."""
+ result = safe_decode_bytes(None)
+ assert isinstance(result, str)
+
+ def test_int_returns_str(self):
+ """Non-string, non-bytes input should be converted to string."""
+ result = safe_decode_bytes(42)
+ assert result == "42"
+
+
+# ======================== format_text_data ===================================
+
+
+class TestFormatTextData:
+ """Tests for format_text_data."""
+
+ def test_none_returns_unreadable(self):
+ """None input should return a string indicating the data is unreadable."""
+ result = format_text_data(None)
+ assert "unreadable" in result.lower()
+
+ def test_string_passthrough(self):
+ """String input should be returned unchanged."""
+ result = format_text_data("some text")
+ assert "some text" in result
+
+ def test_numeric(self):
+ """Numeric input should be converted to string."""
+ result = format_text_data(42)
+ assert "42" in result
+
+
+# ======================== fix_ldata / fix_ndata ==============================
+
+
+class TestFixFunctions:
+ """Tests for fix_ldata and fix_ndata."""
+
+ def test_fix_ldata_string(self):
+ """String input should be returned unchanged."""
+ assert fix_ldata("hello") == "hello"
+
+ def test_fix_ldata_bytes(self):
+ """Bytes input should be decoded to a string."""
+ result = fix_ldata(np.bytes_(b"test"))
+ assert result == "test"
+
+ def test_fix_ldata_none(self):
+ """None input should be converted to an empty string."""
+ assert fix_ldata(None) == ""
+
+ def test_fix_ndata_int(self):
+ """Integer input should be returned unchanged."""
+ assert fix_ndata(5) == 5
+
+ def test_fix_ndata_float(self):
+ """Float input should be returned unchanged."""
+ assert fix_ndata(3.14) == 3.14
+
+ def test_fix_ndata_none(self):
+ """None input should be returned unchanged."""
+ assert fix_ndata(None) is None
+
+ def test_fix_ndata_string(self):
+ """String input should be converted to None."""
+ assert fix_ndata("not a number") is None
+
+
+# ======================== dtype checks =======================================
+
+
+class TestDtypeChecks:
+ """Tests for is_supported_num_dtype and is_supported_str_dtype."""
+
+ def test_int_dtype(self):
+ """Integer dtype should be supported."""
+ data = np.array([1, 2, 3], dtype=np.int32)
+ assert is_supported_num_dtype(data) is True
+
+ def test_float_dtype(self):
+ """Float dtype should be supported."""
+ data = np.array([1.0, 2.0], dtype=np.float64)
+ assert is_supported_num_dtype(data) is True
+
+ def test_complex_dtype(self):
+ """Complex dtype should be supported."""
+ data = np.array([1 + 2j], dtype=np.complex128)
+ assert is_supported_num_dtype(data) is True
+
+ def test_bool_dtype_not_num(self):
+ """Boolean dtype should not be considered a supported numeric dtype."""
+ data = np.array([True, False])
+ assert is_supported_num_dtype(data) is False
+
+ def test_uint_dtype(self):
+ """Unsigned integer dtype should be supported."""
+ data = np.array([1, 2], dtype=np.uint16)
+ assert is_supported_num_dtype(data) is True
+
+ def test_is_single_str_array_false_for_generic_scalar(self):
+ """An ``ndarray`` (not a numpy generic) is rejected."""
+ scalar = np.array(["x"], dtype=str)[0:1] # ndarray, not generic
+ assert is_single_str_array(scalar) is False
+
+ def test_is_single_str_array_false_for_ndarray(self):
+ """A multi-element ndarray of strings is not a single string array."""
+ arr = np.array(["a", "b"])
+ assert is_single_str_array(arr) is False
+
+ def test_supported_str_dtype_false_for_bytes_array(self):
+ """NumPy bytes-dtype arrays are not classified as string-supported."""
+ arr = np.array([b"x", b"y"], dtype="S2")
+ # numpy bytes dtype name starts with "bytes" not "string" -> expected False
+ assert is_supported_str_dtype(arr) is False
+
+ def test_supported_str_dtype_false_for_int(self):
+ """Numeric arrays are not string-supported."""
+ assert is_supported_str_dtype(np.zeros(3, dtype=np.int32)) is False
+
+
+# ======================== process_scalar_value / process_label / process_xy ==
+
+
+@pytest.fixture(name="h5_with_datasets")
+def _h5_with_datasets(tmp_path):
+ """Build a small in-memory HDF5 file containing typical layouts."""
+ path = tmp_path / "fixture.h5"
+ with h5py.File(path, "w") as f:
+ # Scalar value as a 1-element array (the common LMJ layout)
+ f.create_dataset("scalar", data=np.array([42.5]))
+ # Label as a 2-element string list
+ f.create_dataset("label2", data=np.array([b"X-Axis", b"Y-Axis"], dtype="S20"))
+ # Label as a 3-element string list
+ f.create_dataset("label3", data=np.array([b"X", b"Y", b"Z"], dtype="S20"))
+ # x/y pair
+ f.create_dataset("xy", data=np.array([1.5, 2.5]))
+ yield path
+
+
+class TestProcessScalarValue:
+ """Tests for process_scalar_value."""
+
+ def test_returns_callback_result(self, h5_with_datasets):
+ """The callback is applied to the dataset's first element."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ result = process_scalar_value(f, "scalar", float)
+ assert result == pytest.approx(42.5)
+
+ def test_missing_dataset_returns_none(self, h5_with_datasets):
+ """A missing dataset path yields None."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ result = process_scalar_value(f, "missing", float)
+ assert result is None
+
+
+class TestProcessLabel:
+ """Tests for process_label."""
+
+ def test_two_element_label(self, h5_with_datasets):
+ """A two-element label dataset fills (x, y, "")."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ xl, yl, zl = process_label(f, "label2")
+ assert xl == "X-Axis"
+ assert yl == "Y-Axis"
+ assert zl == ""
+
+ def test_three_element_label(self, h5_with_datasets):
+ """A three-element label dataset fills (x, y, z)."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ xl, yl, zl = process_label(f, "label3")
+ assert (xl, yl, zl) == ("X", "Y", "Z")
+
+ def test_missing_returns_empty_strings(self, h5_with_datasets):
+ """A missing label dataset returns three empty strings."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ result = process_label(f, "missing")
+ assert result == ("", "", "")
+
+
+class TestProcessXyValues:
+ """Tests for process_xy_values."""
+
+ def test_returns_pair(self, h5_with_datasets):
+ """A two-element dataset is returned as a (x, y) pair."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ x, y = process_xy_values(f, "xy")
+ assert x == pytest.approx(1.5)
+ assert y == pytest.approx(2.5)
+
+ def test_missing_returns_none_pair(self, h5_with_datasets):
+ """A missing dataset returns (None, None)."""
+ with h5py.File(h5_with_datasets, "r") as f:
+ x, y = process_xy_values(f, "missing")
+ assert x is None
+ assert y is None
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/hdf5/test_h5browser_all_files.py b/sigimax/tests/hdf5/test_h5browser_all_files.py
new file mode 100644
index 0000000..d37d180
--- /dev/null
+++ b/sigimax/tests/hdf5/test_h5browser_all_files.py
@@ -0,0 +1,34 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+HDF5 browser unit tests 1
+-------------------------
+
+Try and open all HDF5 test data available.
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import pytest
+from guidata.qthelpers import exec_dialog, qt_app_context
+
+from sigimax.tests import helpers
+from sigimax.tests.hdf5.test_h5browser_app import create_h5browser_dialog
+
+pytestmark = pytest.mark.gui
+
+
+def test_h5browser_all_files(pattern=None):
+ """HDF5 browser unit test for all available .h5 test files"""
+ with qt_app_context():
+ fnames = helpers.get_test_fnames("*.h5" if pattern is None else pattern)
+ for index, fname in enumerate(fnames):
+ dlg = create_h5browser_dialog([fname], toggle_all=True, select_all=True)
+ dlg.setObjectName(dlg.objectName() + f"_{index:02d}")
+ exec_dialog(dlg)
+
+
+if __name__ == "__main__":
+ test_h5browser_all_files()
diff --git a/sigimax/tests/hdf5/test_h5browser_app.py b/sigimax/tests/hdf5/test_h5browser_app.py
new file mode 100644
index 0000000..5f86b89
--- /dev/null
+++ b/sigimax/tests/hdf5/test_h5browser_app.py
@@ -0,0 +1,80 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+HDF5 Browser Application test
+-----------------------------
+
+
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import pytest
+from guidata.qthelpers import exec_dialog, qt_app_context
+from qtpy import QtWidgets as QW
+
+from sigimax.env import execenv
+from sigimax.tests import helpers
+from sigimax.widgets.h5browser import H5BrowserDialog
+
+pytestmark = pytest.mark.gui
+
+
+def create_h5browser_dialog(
+ fnames: list[str], toggle_all: bool = False, select_all: bool = False
+) -> H5BrowserDialog:
+ """Create HDF5 browser dialog with all nodes expanded and selected
+
+ Args:
+ fnames: HDF5 file names
+
+ Returns:
+ H5BrowserDialog instance
+ """
+ execenv.print(f"Opening: {fnames}")
+ dlg = H5BrowserDialog(None)
+ dlg.open_files(fnames)
+ dlg.browser.tree.toggle_all(toggle_all)
+ dlg.browser.tree.select_all(select_all)
+ return dlg
+
+
+def test_h5browser() -> None:
+ """Test HDF5 browser"""
+ fnames = helpers.get_test_fnames("*.h5")[-2:]
+ with qt_app_context():
+ dlg = create_h5browser_dialog(fnames)
+
+ if execenv.unattended:
+ # Test all buttons:
+ dlg.show()
+ for index in range(dlg.button_layout.count()):
+ widget = dlg.button_layout.itemAt(index).widget()
+ if isinstance(widget, QW.QCheckBox):
+ widget.setChecked(True)
+ widget.setChecked(False)
+ elif isinstance(widget, QW.QPushButton):
+ widget.click()
+
+ # Test various features:
+ tree = dlg.browser.tree
+ tree.update_menu()
+ tree.expandAll()
+ tree.collapseAll()
+ tree.restore()
+
+ # Removing file, adding file from browser:
+ dlg.browser.close_file(fnames[0])
+ dlg.browser.open_file(fnames[0])
+
+ # Removing file, adding file from file selector:
+ dlg.browser.selector.remove_file(fnames[0])
+ dlg.browser.selector.add_file(fnames[0])
+
+ exec_dialog(dlg)
+
+
+if __name__ == "__main__":
+ test_h5browser()
diff --git a/sigimax/tests/hdf5/test_h5import.py b/sigimax/tests/hdf5/test_h5import.py
new file mode 100644
index 0000000..75f1fb3
--- /dev/null
+++ b/sigimax/tests/hdf5/test_h5import.py
@@ -0,0 +1,26 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+HDF5 import application test
+"""
+
+# guitest: show
+
+import pytest
+
+from sigimax.env import execenv
+from sigimax.tests import helpers, sigimax_test_app_context
+
+pytestmark = pytest.mark.app
+
+
+def test_hdf5_import():
+ """Testing SigimaX app launcher"""
+ with sigimax_test_app_context(console=False) as win:
+ fname = helpers.get_test_fnames("*.h5")[-1]
+ execenv.print(f"Importing HDF5 file: {fname}")
+ win.import_all_from_h5_file(fname)
+
+
+if __name__ == "__main__":
+ test_hdf5_import()
diff --git a/sigimax/tests/mainwindow/__init__.py b/sigimax/tests/mainwindow/__init__.py
new file mode 100644
index 0000000..792d600
--- /dev/null
+++ b/sigimax/tests/mainwindow/__init__.py
@@ -0,0 +1 @@
+#
diff --git a/sigimax/tests/mainwindow/test_app_create.py b/sigimax/tests/mainwindow/test_app_create.py
new file mode 100644
index 0000000..987cba4
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_app_create.py
@@ -0,0 +1,73 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for the application launcher (:mod:`sigimax.app`)
+-------------------------------------------------------
+
+Covers:
+- create() with default args → returns SGMXMainWindow
+- create() with console=True → window has console
+- create() with custom size → window geometry matches
+- create() with custom window_class → returns correct subclass
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from sigimax.app import create as sigimax_create
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils.qthelpers import sigimax_app_context
+
+pytestmark = pytest.mark.app
+
+
+class _TestSubWindow(SGMXMainWindow):
+ """Minimal subclass for testing window_class parameter."""
+
+ CUSTOM_MARKER = True
+
+ def __init__(self, console=None, hide_on_close=False):
+ super().__init__(console=console, hide_on_close=hide_on_close)
+
+
+def test_create_default():
+ """create() with default args returns a SGMXMainWindow instance."""
+ with sigimax_app_context(exec_loop=False):
+ win = sigimax_create(splash=False)
+ assert isinstance(win, SGMXMainWindow)
+ win.close()
+
+
+def test_create_with_console():
+ """create() with console=True → window has an embedded console."""
+ with sigimax_app_context(exec_loop=False):
+ win = sigimax_create(splash=False, console=True)
+ assert isinstance(win, SGMXMainWindow)
+ # Console dock should exist
+ assert win.docks is not None
+ win.close()
+
+
+def test_create_custom_size():
+ """create() with custom size → window should be resized."""
+ width, height = 800, 500
+ with sigimax_app_context(exec_loop=False):
+ win = sigimax_create(splash=False, size=(width, height))
+ assert win.width() == width
+ assert win.height() == height
+ win.close()
+
+
+def test_create_custom_window_class():
+ """create() with a custom window_class returns an instance of that class."""
+ with sigimax_app_context(exec_loop=False):
+ win = sigimax_create(window_class=_TestSubWindow, splash=False)
+ assert isinstance(win, _TestSubWindow)
+ assert hasattr(win, "CUSTOM_MARKER")
+ assert win.CUSTOM_MARKER is True
+ win.close()
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/mainwindow/test_console_hooks.py b/sigimax/tests/mainwindow/test_console_hooks.py
new file mode 100644
index 0000000..5e14a05
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_console_hooks.py
@@ -0,0 +1,154 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Console hooks functional test
+-------------------------------
+
+Verify that:
+
+1. The base :class:`SGMXMainWindow` creates a working console with a generic
+ namespace (``win``, ``np``, etc.) and a generic welcome message.
+2. A derived application can override :meth:`_get_console_namespace` and
+ :meth:`_get_console_message` to inject custom variables and a custom
+ welcome message.
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from sigimax.config import CONF as Conf
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils import qthelpers as qth
+
+pytestmark = pytest.mark.app
+
+# =============================================================================
+# Derived window with custom console namespace
+# =============================================================================
+
+
+class CustomConsoleWindow(SGMXMainWindow):
+ """Derived window that adds domain-specific variables to the console."""
+
+ CUSTOM_DATA = np.array([1, 2, 3, 4, 5])
+
+ def __init__(self) -> None:
+ Conf.app_name.set("ConsoleHookTest")
+ Conf.app_version.set("0.0.1")
+ super().__init__(console=True)
+
+ def _get_console_namespace(self) -> dict[str, object]:
+ """Add custom variables alongside the defaults."""
+ ns = super()._get_console_namespace()
+ ns["app"] = self # alias
+ ns["data"] = self.CUSTOM_DATA
+ ns["magic"] = 42
+ return ns
+
+ def _get_console_message(self) -> str:
+ """Provide a domain-specific welcome message."""
+ return (
+ "Welcome to ConsoleHookTest console!\n"
+ "Custom variables: app, data, magic\n"
+ "Example:\n"
+ " data # sample array\n"
+ " magic # the answer\n"
+ " app == win # True — both reference the main window"
+ )
+
+
+# =============================================================================
+# Tests
+# =============================================================================
+
+
+def test_console_base():
+ """Verify that the base SGMXMainWindow console has the expected namespace
+ and welcome message."""
+ with qth.sigimax_app_context(exec_loop=False):
+ Conf.app_name.set("BaseConsoleTest")
+ Conf.app_version.set("0.0.1")
+
+ win = SGMXMainWindow(console=True)
+ win.resize(800, 500)
+ win.show()
+
+ # Console must have been created
+ assert win.console is not None, "Console was not created"
+
+ # -- Check namespace contents ----------------------------------------
+ ns = win.console.interpreter.locals
+ assert "win" in ns, f"'win' missing from namespace: {list(ns)}"
+ assert ns["win"] is win, "'win' should reference the main window"
+ assert "np" in ns, f"'np' missing from namespace: {list(ns)}"
+ assert ns["np"] is np, "'np' should be numpy"
+
+ expected_keys = {"win", "np", "sps", "spi", "os", "sys", "osp", "time"}
+ assert expected_keys.issubset(ns.keys()), (
+ f"Missing keys: {expected_keys - ns.keys()}"
+ )
+
+ # DataLab-specific names must NOT be present
+ assert "dl" not in ns, "'dl' should not be in base namespace"
+
+ # -- Check welcome message -------------------------------------------
+ msg = win._get_console_message() # pylint: disable=protected-access
+ assert "win" in msg, "Welcome message should mention 'win'"
+ assert Conf.app_name.get() in msg, "Welcome message should contain the app name"
+
+ # -- Clean close -----------------------------------------------------
+ win.set_modified(False)
+ win.close()
+
+ print("Base console test passed.")
+
+
+def test_console_derived():
+ """Verify that a derived window can inject custom variables and message
+ into the console."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = CustomConsoleWindow()
+ win.resize(800, 500)
+ win.show()
+
+ assert win.console is not None, "Console was not created"
+
+ # -- Check that custom variables are present in namespace -------------
+ ns = win.console.interpreter.locals
+
+ assert "app" in ns, f"'app' missing from namespace: {list(ns)}"
+ assert ns["app"] is win, "'app' should reference the main window"
+ assert "data" in ns, f"'data' missing from namespace: {list(ns)}"
+ assert (ns["data"] == CustomConsoleWindow.CUSTOM_DATA).all(), (
+ "'data' should be the custom array"
+ )
+ assert "magic" in ns, f"'magic' missing from namespace: {list(ns)}"
+ assert ns["magic"] == 42, "'magic' should be 42"
+
+ # -- Default variables should still be present (via super()) ----------
+ assert "win" in ns, "'win' should still be present"
+ assert ns["win"] is win, "'win' should reference the main window"
+ assert "np" in ns, "'np' should still be present"
+
+ # -- Check custom welcome message -------------------------------------
+ msg = win._get_console_message() # pylint: disable=protected-access
+ assert "ConsoleHookTest" in msg, (
+ "Custom welcome message should mention the app name"
+ )
+ assert "magic" in msg, "Custom welcome message should mention 'magic'"
+ assert "data" in msg, "Custom welcome message should mention 'data'"
+
+ # -- Clean close -----------------------------------------------------
+ win.set_modified(False)
+ win.close()
+
+ print("Derived console test passed.")
+
+
+if __name__ == "__main__":
+ test_console_base()
+ test_console_derived()
diff --git a/sigimax/tests/mainwindow/test_derived_app.py b/sigimax/tests/mainwindow/test_derived_app.py
new file mode 100644
index 0000000..7a09a4a
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_derived_app.py
@@ -0,0 +1,406 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Derived application example test
+---------------------------------
+
+This test demonstrates how to build a custom application on top of SigimaX by:
+
+1. Subclassing :class:`SigimaXOptions` to add application-specific options.
+2. Subclassing :class:`SGMXMainWindow` to customize the main window (menus,
+ toolbars, dockable widgets, etc.).
+
+The resulting "MyApp" application showcases the full derivation pattern that
+downstream projects (like DataLab) can follow.
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from guidata.configtools import get_icon
+from guidata.qthelpers import add_actions, create_action
+from plotpy.constants import PlotType
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from sigimax.app import create as sigimax_create
+from sigimax.config import CONF as Conf
+from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField, _
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils import qthelpers as qth
+from sigimax.widgets.plotdock import DockablePlotWidget
+from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig
+
+# =============================================================================
+# 1. Derived configuration: MyAppOptions
+# =============================================================================
+
+
+class MyAppOptions(SigimaXOptions):
+ """Custom options for the MyApp application.
+
+ Extends :class:`SigimaXOptions` with application-specific settings
+ such as a greeting message, max recent files, and a default unit system.
+ """
+
+ APP_NAME = "MyApp"
+ CONF_VERSION = "1.0.0"
+
+ def __init__(self) -> None:
+ super().__init__()
+
+ # Override default application metadata
+ self.app_name.set("MyApp")
+ self.app_version.set("0.1.0")
+ self.app_desc.set("A demo application built on SigimaX")
+
+ # --- Application-specific options ---
+
+ self.greeting_message = TypedOptionField(
+ self,
+ "greeting_message",
+ default="Welcome to MyApp!",
+ expected_type=str,
+ description="Message displayed in the status bar on startup.",
+ )
+ self.max_recent_files = TypedOptionField(
+ self,
+ "max_recent_files",
+ default=10,
+ expected_type=int,
+ description="Maximum number of recent files to remember.",
+ )
+ self.default_unit_system = EnumOptionField(
+ self,
+ "default_unit_system",
+ default="metric",
+ choices=["metric", "imperial"],
+ description="Default unit system for display.",
+ )
+ self.auto_compute_on_load = TypedOptionField(
+ self,
+ "auto_compute_on_load",
+ default=False,
+ expected_type=bool,
+ description=(
+ "If True, automatically run default computations "
+ "when loading a dataset."
+ ),
+ )
+
+ # Recapture defaults after adding custom options
+ self._defaults.update(
+ {
+ name: getattr(self, name).get()
+ for name in (
+ "greeting_message",
+ "max_recent_files",
+ "default_unit_system",
+ "auto_compute_on_load",
+ )
+ }
+ )
+
+
+# =============================================================================
+# 2. Derived main window: MyAppMainWindow
+# =============================================================================
+
+
+class MyAppMainWindow(SGMXMainWindow):
+ """Custom main window for the MyApp application.
+
+ Extends :class:`SGMXMainWindow` with:
+
+ - A custom "Tools" menu with domain-specific actions.
+ - A dockable curve plot widget.
+ - A demo action that generates a sine wave and displays it.
+
+ The pattern for derived windows is:
+
+ 1. Configure the global ``Conf`` options (app_name, app_version, etc.)
+ **before** calling ``super().__init__()``, because :class:`SGMXMainWindow`
+ reads from the module-level ``Conf`` reference.
+ 2. Add custom UI elements by overriding the ``setup()`` hooks, so that docks
+ exist before the persisted window state is restored.
+ """
+
+ def __init__(
+ self,
+ console: bool | None = None,
+ hide_on_close: bool = False,
+ ) -> None:
+ # Configure global Conf BEFORE calling super().__init__() so that
+ # SGMXMainWindow reads the correct app_name, app_version, etc.
+ Conf.app_name.set("MyApp")
+ Conf.app_version.set("0.1.0")
+ Conf.app_desc.set("A demo application built on SigimaX")
+
+ # --- Custom widgets ---
+ self.curve_dock: DockablePlotWidget | None = None
+
+ super().__init__(console=console, hide_on_close=hide_on_close)
+
+ # ------------------------------------------------------------------
+ # Custom UI setup
+ # ------------------------------------------------------------------
+
+ def _setup_docks(self) -> None:
+ """Add MyApp-specific dock widgets."""
+ self._add_curve_dock()
+
+ def _post_setup(self, console: bool) -> None:
+ """Add MyApp-specific menus and toolbars."""
+ self._add_tools_menu()
+ self._add_custom_toolbar()
+
+ def _add_curve_dock(self) -> None:
+ """Add a dockable curve plot widget to the main window."""
+ self.curve_dock = DockablePlotWidget(self, PlotType.CURVE)
+ self._add_dockwidget(self.curve_dock, _("Curve Viewer"), name="curve_viewer")
+
+ def _add_tools_menu(self) -> None:
+ """Add a custom 'Tools' menu to the menu bar."""
+ tools_menu = self.menuBar().addMenu(_("&Tools"))
+
+ generate_action = create_action(
+ self,
+ _("Generate sine wave"),
+ icon=get_icon("new_signal.svg"),
+ tip=_("Generate a sample sine wave and display it"),
+ triggered=self._generate_sine_wave,
+ )
+ clear_action = create_action(
+ self,
+ _("Clear plot"),
+ icon=get_icon("libre-gui-close.svg"),
+ tip=_("Remove all curves from the plot"),
+ triggered=self._clear_plot,
+ )
+ show_options_action = create_action(
+ self,
+ _("Show configuration"),
+ tip=_("Print all current configuration options to the console"),
+ triggered=self._show_configuration,
+ )
+ add_actions(
+ tools_menu, [generate_action, clear_action, None, show_options_action]
+ )
+
+ def _add_custom_toolbar(self) -> None:
+ """Add a custom toolbar with quick-access actions."""
+ toolbar = QW.QToolBar(_("MyApp Tools"), self)
+ toolbar.setObjectName("myapp_tools_toolbar")
+ self.addToolBar(QC.Qt.TopToolBarArea, toolbar)
+
+ generate_action = create_action(
+ self,
+ _("Sine"),
+ icon=get_icon("new_signal.svg"),
+ tip=_("Generate a sine wave"),
+ triggered=self._generate_sine_wave,
+ )
+ toolbar.addAction(generate_action)
+
+ # ------------------------------------------------------------------
+ # Custom actions
+ # ------------------------------------------------------------------
+
+ def _generate_sine_wave(self) -> None:
+ """Generate a sine wave and display it in the curve dock."""
+ if self.curve_dock is None:
+ return
+ x = np.linspace(0, 4 * np.pi, 500)
+ y = np.sin(x) + 0.1 * np.random.randn(len(x))
+
+ plot = self.curve_dock.get_plot()
+ from plotpy.builder import make # pylint: disable=import-outside-toplevel
+
+ curve = make.curve(x, y, title="sin(x) + noise", color="blue")
+ plot.add_item(curve)
+ plot.do_autoscale()
+
+ self.statusBar().showMessage(
+ _("Generated sine wave with %d points") % len(x), 3000
+ )
+
+ def _clear_plot(self) -> None:
+ """Remove all items from the curve dock plot."""
+ if self.curve_dock is None:
+ return
+ plot = self.curve_dock.get_plot()
+ plot.del_all_items()
+ plot.replot()
+ self.statusBar().showMessage(_("Plot cleared"), 2000)
+
+ def _show_configuration(self) -> None:
+ """Print all configuration options to stdout (and console if available)."""
+ print("\n--- MyApp Configuration ---")
+ Conf.describe_all()
+ print("---\n")
+
+
+# =============================================================================
+# 3. Test function
+# =============================================================================
+
+
+@pytest.mark.unit
+def test_derived_app():
+ """Test that a derived application can be built on top of SigimaX."""
+ # -- Verify custom options work --
+ conf = MyAppOptions()
+ assert conf.app_name.get() == "MyApp"
+ assert conf.app_version.get() == "0.1.0"
+ assert conf.greeting_message.get() == "Welcome to MyApp!"
+ assert conf.max_recent_files.get() == 10
+ assert conf.default_unit_system.get() == "metric"
+ assert conf.auto_compute_on_load.get() is False
+
+ # Test option modification
+ conf.greeting_message.set("Hello, World!")
+ assert conf.greeting_message.get() == "Hello, World!"
+
+ # Test context manager override
+ with conf.max_recent_files.context(5):
+ assert conf.max_recent_files.get() == 5
+ assert conf.max_recent_files.get() == 10
+
+ # Test reset to defaults
+ conf.greeting_message.set("Changed")
+ conf.reset_to_defaults()
+ assert conf.greeting_message.get() == "Welcome to MyApp!"
+
+ # Test serialization round-trip
+ d = conf.to_dict()
+ assert "greeting_message" in d
+ assert "max_recent_files" in d
+ assert d["default_unit_system"] == "metric"
+
+ conf2 = MyAppOptions()
+ conf2.from_dict(d)
+ assert conf2.greeting_message.get() == conf.greeting_message.get()
+ assert conf2.max_recent_files.get() == conf.max_recent_files.get()
+
+ # Test enum validation
+ try:
+ conf.default_unit_system.set("invalid_unit")
+ assert False, "Should have raised ValueError"
+ except ValueError:
+ pass # Expected
+
+ # Test list_options includes custom options
+ option_names = conf.list_options()
+ assert "greeting_message" in option_names
+ assert "max_recent_files" in option_names
+ assert "default_unit_system" in option_names
+ assert "auto_compute_on_load" in option_names
+ # Also includes inherited SigimaX options
+ assert "color_mode" in option_names
+ assert "console_enabled" in option_names
+
+ print("All custom option tests passed.")
+
+
+@pytest.mark.app
+def test_splash_screen():
+ """Test that the splash screen can be created and shown."""
+ with qth.sigimax_app_context(exec_loop=False):
+ # Test 1: Splash screen from explicit config (fallback pixmap, no image)
+ config = SplashScreenConfig(
+ app_name="MyApp",
+ app_version="0.1.0",
+ tagline="A demo application",
+ show_progress=True,
+ )
+ assert not config.is_enabled # No image_path => disabled
+
+ # Test 2: Splash screen with a non-existent image (fallback)
+ config_with_path = SplashScreenConfig(
+ image_path="nonexistent.png",
+ app_name="MyApp",
+ app_version="0.1.0",
+ )
+ assert config_with_path.is_enabled
+
+ splash = SigimaXSplashScreen(config_with_path)
+ splash.show()
+ splash.show_message("Loading test...")
+ splash.close()
+
+ # Test 3: from_conf returns None when no splash image is configured
+ splash_from_conf = SigimaXSplashScreen.from_conf()
+ assert splash_from_conf is None # Default config has no splash image
+
+ # Test 4: create() launcher works without splash
+ win = sigimax_create(
+ window_class=MyAppMainWindow,
+ splash=False,
+ console=False,
+ size=(800, 600),
+ )
+ assert win is not None
+ win.set_modified(False)
+ win.close()
+
+ print("Splash screen tests passed.")
+
+
+@pytest.mark.app
+def test_derived_app_window():
+ # pylint: disable=protected-access
+ # pylint: disable=redefined-outer-name
+ """Test that the derived main window creates and runs properly."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = MyAppMainWindow(console=False)
+ win.resize(1200, 700)
+ win.show()
+
+ # Verify window title contains our app name
+ assert "MyApp" in win.windowTitle()
+
+ # Verify the curve dock was created
+ assert win.curve_dock is not None
+
+ # Test generating a sine wave
+ win._generate_sine_wave()
+ plot = win.curve_dock.get_plot()
+ items_before_generate = len(plot.get_items())
+ assert items_before_generate > 0
+
+ # Test clearing the plot — note that the plot may keep internal
+ # items (e.g., tool markers), so we just check the count decreased
+ initial_count = len(plot.get_items())
+ win._generate_sine_wave() # add another curve
+ assert len(plot.get_items()) > initial_count
+ win._clear_plot()
+
+ # Test show configuration (just ensure it doesn't crash)
+ win._show_configuration()
+
+ # Clean close
+ win.set_modified(False)
+ win.close()
+
+ print("Derived main window test passed.")
+
+
+if __name__ == "__main__":
+ from sigimax.app import run as sigimax_run
+
+ # Launch with splash screen (fallback pixmap since no image is provided)
+ splash_config = SplashScreenConfig(
+ image_path="nonexistent_demo.png", # Will use fallback pixmap
+ app_name="MyApp",
+ app_version="0.1.0",
+ tagline="A demo application built on SigimaX",
+ )
+ sigimax_run(
+ window_class=MyAppMainWindow,
+ splash_config=splash_config,
+ console=True,
+ size=(1200, 700),
+ )
diff --git a/sigimax/tests/mainwindow/test_lifecycle_hooks.py b/sigimax/tests/mainwindow/test_lifecycle_hooks.py
new file mode 100644
index 0000000..0792ae1
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_lifecycle_hooks.py
@@ -0,0 +1,152 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Main-window lifecycle hook tests."""
+
+from __future__ import annotations
+
+import pytest
+from qtpy import QtWidgets as QW
+
+from sigimax.config import CONF as Conf
+from sigimax.env import execenv
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils import qthelpers as qth
+
+
+class HookWindow(SGMXMainWindow):
+ """Window recording the protected setup and persistence hooks."""
+
+ def __init__(self) -> None:
+ self.hook_calls: list[str] = []
+ super().__init__(console=False)
+
+ def _update_color_mode(self, startup: bool = False) -> None:
+ self.hook_calls.append("color")
+ super()._update_color_mode(startup=startup)
+
+ def _before_setup(self, console: bool) -> None:
+ self.hook_calls.append("before")
+ super()._before_setup(console)
+
+ def _configure_statusbar(self, console: bool) -> None:
+ self.hook_calls.append("statusbar")
+ super()._configure_statusbar(console)
+
+ def _setup_global_actions(self) -> None:
+ self.hook_calls.append("actions")
+ super()._setup_global_actions()
+
+ def _setup_central_widget(self) -> None:
+ self.hook_calls.append("central")
+ super()._setup_central_widget()
+
+ def _add_menus(self) -> None:
+ self.hook_calls.append("menus")
+ super()._add_menus()
+
+ def _restore_state(self) -> None:
+ self.hook_calls.append("state")
+ super()._restore_state()
+
+ def _restore_pos_and_size(self) -> None:
+ self.hook_calls.append("geometry")
+ super()._restore_pos_and_size()
+
+ def _after_setup(self, console: bool) -> None:
+ self.hook_calls.append("after")
+ super()._after_setup(console)
+
+ def _save_pos_size_and_state(self) -> None:
+ self.hook_calls.append("save")
+ super()._save_pos_size_and_state()
+
+ def _close_managed_widgets(self) -> None:
+ self.hook_calls.append("close_widgets")
+ super()._close_managed_widgets()
+
+ def _cleanup_before_reset(self) -> None:
+ self.hook_calls.append("before_reset")
+ super()._cleanup_before_reset()
+
+ def _cleanup_after_state_save(self) -> None:
+ self.hook_calls.append("after_save")
+ super()._cleanup_after_state_save()
+
+
+class PreparedColorWindow(SGMXMainWindow):
+ """Window whose color hook requires the documented setup preparation."""
+
+ def __init__(self) -> None:
+ self.color_ready = False
+ super().__init__(console=False)
+
+ def _before_setup(self, console: bool) -> None:
+ self.color_ready = True
+ super()._before_setup(console)
+
+ def _update_color_mode(self, startup: bool = False) -> None:
+ assert self.color_ready
+ super()._update_color_mode(startup=startup)
+
+
+class DerivedInstanceWindow(SGMXMainWindow):
+ """Derived class used to verify class-specific singleton construction."""
+
+
+def test_lifecycle_hooks() -> None:
+ """Protected lifecycle hooks are overridable and keep a stable call order."""
+ Conf.app_name.set("LifecycleHookTest")
+ with qth.sigimax_app_context(exec_loop=False):
+ window = HookWindow()
+ assert window.hook_calls == [
+ "before",
+ "color",
+ "statusbar",
+ "actions",
+ "central",
+ "menus",
+ "state",
+ "after",
+ "geometry",
+ ]
+ window._save_pos_size_and_state() # pylint: disable=protected-access
+ assert window.hook_calls[-1] == "save"
+ assert window.close_properly()
+ assert window.hook_calls[-4:] == [
+ "close_widgets",
+ "before_reset",
+ "save",
+ "after_save",
+ ]
+
+
+def test_before_setup_precedes_color_hook() -> None:
+ """Test that derived setup runs before the color-mode hook."""
+ with qth.sigimax_app_context(exec_loop=False):
+ window = PreparedColorWindow()
+ window.close()
+
+
+def test_get_instance_preserves_derived_window_class() -> None:
+ """Test that get_instance creates the class on which it is called."""
+ with qth.sigimax_app_context(exec_loop=False):
+ base_window = SGMXMainWindow(console=False)
+ derived_window = DerivedInstanceWindow.get_instance(console=False)
+ assert isinstance(derived_window, DerivedInstanceWindow)
+ base_window.close()
+ derived_window.close()
+
+
+def test_failed_save_cancels_close(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Test that a failed Save choice preserves the modified workspace."""
+ with qth.sigimax_app_context(exec_loop=False):
+ window = SGMXMainWindow(console=False)
+ window.set_modified(True)
+ monkeypatch.setattr(execenv, "unattended", False)
+ monkeypatch.setattr(QW.QMessageBox, "warning", lambda *args: QW.QMessageBox.Yes)
+ monkeypatch.setattr(window, "save_to_h5_file", lambda: None)
+
+ assert not window.close_properly()
+ assert window.is_modified()
+ window.set_modified(False)
+ window.close()
diff --git a/sigimax/tests/mainwindow/test_local_doc_path.py b/sigimax/tests/mainwindow/test_local_doc_path.py
new file mode 100644
index 0000000..bd39cb3
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_local_doc_path.py
@@ -0,0 +1,105 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Local PDF doc path unit tests
+------------------------------
+
+Tests for the ``__get_local_doc_path`` static method on ``SGMXMainWindow``,
+which resolves a configurable path pattern to a locale-aware PDF file.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+from qtpy import QtCore as QC
+
+from sigimax.config import CONF as Conf
+from sigimax.mainwindow import SGMXMainWindow
+
+pytestmark = pytest.mark.unit
+
+# Access the name-mangled static method without instantiating a window
+_get_local_doc_path = (
+ SGMXMainWindow._SGMXMainWindow__get_local_doc_path # pylint: disable=protected-access
+)
+
+
+@pytest.fixture(autouse=True)
+def _reset_doc_path():
+ """Reset app_local_doc_path to empty after each test."""
+ yield
+ Conf.app_local_doc_path.set("")
+
+
+@pytest.fixture()
+def pdf_files(tmp_path): # pylint: disable=redefined-outer-name
+ """Create fake locale-aware PDF files and return the directory."""
+ (tmp_path / "MyApp_fr.pdf").write_text("fake", encoding="utf-8")
+ (tmp_path / "MyApp_en.pdf").write_text("fake", encoding="utf-8")
+ return tmp_path
+
+
+class TestLocalDocPath:
+ """Tests for SGMXMainWindow.__get_local_doc_path."""
+
+ def test_empty_config_returns_none(self):
+ """No path configured → None."""
+ Conf.app_local_doc_path.set("")
+ assert _get_local_doc_path() is None
+
+ def test_lang_placeholder_resolves_locale(
+ self,
+ pdf_files, # pylint: disable=redefined-outer-name
+ ):
+ """Pattern with {lang} resolves to the system locale file."""
+ pattern = str(pdf_files / "MyApp_{lang}.pdf")
+ Conf.app_local_doc_path.set(pattern)
+
+ with patch.object(
+ QC.QLocale, "system", return_value=QC.QLocale(QC.QLocale.French)
+ ):
+ result = _get_local_doc_path()
+ assert result is not None
+ assert result.endswith("MyApp_fr.pdf")
+
+ def test_lang_placeholder_falls_back_to_en(
+ self,
+ pdf_files, # pylint: disable=redefined-outer-name
+ ):
+ """Pattern with {lang} falls back to 'en' when locale file is missing."""
+ pattern = str(pdf_files / "MyApp_{lang}.pdf")
+ Conf.app_local_doc_path.set(pattern)
+
+ # Japanese locale → no MyApp_ja.pdf → should fall back to MyApp_en.pdf
+ with patch.object(
+ QC.QLocale, "system", return_value=QC.QLocale(QC.QLocale.Japanese)
+ ):
+ result = _get_local_doc_path()
+ assert result is not None
+ assert result.endswith("MyApp_en.pdf")
+
+ def test_lang_placeholder_no_file_returns_none(self, tmp_path):
+ """Pattern with {lang} but no matching file at all → None."""
+ pattern = str(tmp_path / "Missing_{lang}.pdf")
+ Conf.app_local_doc_path.set(pattern)
+ assert _get_local_doc_path() is None
+
+ def test_direct_path_existing_file(
+ self,
+ pdf_files, # pylint: disable=redefined-outer-name
+ ):
+ """Pattern without {lang} pointing to an existing file → that path."""
+ path = str(pdf_files / "MyApp_en.pdf")
+ Conf.app_local_doc_path.set(path)
+ assert _get_local_doc_path() == path
+
+ def test_direct_path_missing_file(self, tmp_path):
+ """Pattern without {lang} pointing to a missing file → None."""
+ Conf.app_local_doc_path.set(str(tmp_path / "nonexistent.pdf"))
+ assert _get_local_doc_path() is None
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/mainwindow/test_main_window.py b/sigimax/tests/mainwindow/test_main_window.py
new file mode 100644
index 0000000..71b2798
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_main_window.py
@@ -0,0 +1,47 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Application test for main window
+--------------------------------
+
+Testing the features of the main window of the application that are not
+covered by other tests.
+"""
+
+# guitest: show
+
+import pytest
+from plotpy.constants import PlotType
+
+from sigimax.tests import sigimax_test_app_context
+from sigimax.widgets.h5browser import H5Browser
+from sigimax.widgets.plotdock import DockablePlotWidget
+
+
+@pytest.mark.app
+def test_main_app():
+ """Main window test"""
+ with sigimax_test_app_context(console=True) as win:
+ print("Main window test")
+ win.activateWindow()
+
+ # Add two DockablePlotWidget docks
+ for title, plot_type in (
+ ("Curve Plot", PlotType.CURVE),
+ ("Image Plot", PlotType.IMAGE),
+ ):
+ dock_widget = DockablePlotWidget(win, plot_type)
+ dockwidget, location = dock_widget.create_dockwidget(title)
+ win.addDockWidget(location, dockwidget)
+ win.docks[dock_widget] = dockwidget
+
+ # central_widget = SigimaXPlotWidget(plot_type=PlotType.CURVE)
+ central_widget = H5Browser()
+ win.setCentralWidget(central_widget)
+ # win.removeToolBar(win.main_toolbar) # Remove the default toolbar
+ # win.statusBar().hide() # Hide the status bar
+ # win.menuBar().hide() # Hide the menu bar
+
+
+if __name__ == "__main__":
+ test_main_app()
diff --git a/sigimax/tests/mainwindow/test_menu_hooks.py b/sigimax/tests/mainwindow/test_menu_hooks.py
new file mode 100644
index 0000000..75012eb
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_menu_hooks.py
@@ -0,0 +1,235 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Menu hooks functional test
+--------------------------
+
+Verify that a derived application can fully customize all menus
+(file, view, help) by overriding the ``_get_*_menu_actions()`` and
+``_update_*_menu()`` hooks provided by :class:`SGMXMainWindow`.
+
+The test builds a minimal derived window that:
+
+- Prepends a "New project" action to the file menu.
+- Inserts a custom action between the H5 group and settings in the file menu.
+- Overrides ``_is_save_enabled`` to always return ``False``.
+- Appends a "Preferences" action to the view menu.
+- Inserts a "Release notes" action before "About..." in the help menu.
+- Adds a "Web API" separator + action after the default file menu via
+ ``_update_file_menu`` override.
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import pytest
+from guidata.configtools import get_icon
+from guidata.qthelpers import add_actions, create_action
+from qtpy import QtWidgets as QW
+
+from sigimax.config import CONF as Conf
+from sigimax.config import _
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils import qthelpers as qth
+
+pytestmark = pytest.mark.app
+
+# =============================================================================
+# Derived window with custom menu hooks
+# =============================================================================
+
+
+class CustomMenuWindow(SGMXMainWindow):
+ """Derived window exercising every menu hook."""
+
+ def __init__(self) -> None:
+ Conf.app_name.set("MenuHookTest")
+ Conf.app_version.set("0.0.1")
+
+ # Custom actions must be created BEFORE super().__init__() because
+ # __add_menus() → _get_help_menu_actions() is called during setup().
+ # We can use QWidget.__init__ indirectly — create_action only needs a
+ # QObject parent, and ``self`` is already a valid QObject at this point
+ # thanks to Python's MRO (QMainWindow.__init__ hasn't run yet, but
+ # the C++ QObject exists after type.__call__ allocates the instance).
+ # However, since create_action may depend on the widget being fully
+ # initialized, we initialize the attributes to None first and create
+ # the actions in a dedicated method called before super().__init__().
+ self.new_project_action: QW.QAction | None = None
+ self.import_csv_action: QW.QAction | None = None
+ self.webapi_action: QW.QAction | None = None
+ self.preferences_action: QW.QAction | None = None
+ self.release_notes_action: QW.QAction | None = None
+
+ super().__init__(console=False)
+
+ # Now create actions (parent widget is fully initialized)
+ self._create_custom_actions()
+
+ # Rebuild help menu with our custom actions (it was built during
+ # __add_menus with None placeholders)
+ self.help_menu.clear()
+ add_actions(self.help_menu, self._get_help_menu_actions())
+
+ def _create_custom_actions(self) -> None:
+ """Create custom actions after the widget is fully initialized."""
+ self.new_project_action = create_action(
+ self,
+ _("New project"),
+ icon=get_icon("libre-gui-add.svg"),
+ tip=_("Create a new empty project"),
+ )
+ self.import_csv_action = create_action(
+ self,
+ _("Import CSV..."),
+ icon=get_icon("fileopen_signal.svg"),
+ tip=_("Import data from a CSV file"),
+ )
+ self.webapi_action = create_action(
+ self,
+ _("Web API status"),
+ tip=_("Show Web API connection status"),
+ )
+ self.preferences_action = create_action(
+ self,
+ _("Preferences..."),
+ tip=_("Edit application preferences"),
+ )
+ self.release_notes_action = create_action(
+ self,
+ _("Release notes"),
+ tip=_("Show release notes"),
+ )
+
+ # -- File menu hooks -------------------------------------------------------
+
+ def _is_save_enabled(self) -> bool:
+ """Save is disabled when the workspace has no objects."""
+ return False # For testing: always disabled
+
+ def _get_file_menu_actions(self) -> list[QW.QAction | None]:
+ """Prepend 'New project' and insert 'Import CSV' after browse."""
+ return [
+ self.new_project_action,
+ None,
+ self.openh5_action,
+ self.saveh5_action,
+ self.browseh5_action,
+ None,
+ self.import_csv_action,
+ ]
+
+ def _update_file_menu(self) -> None:
+ """Append Web API action after default population."""
+ super()._update_file_menu()
+ self.file_menu.addSeparator()
+ self.file_menu.addAction(self.webapi_action)
+
+ # -- View menu hooks -------------------------------------------------------
+
+ def _get_view_menu_actions(self) -> list[QW.QAction | None]:
+ """Append 'Preferences' at the end of the view menu."""
+ return super()._get_view_menu_actions() + [None, self.preferences_action]
+
+ # -- Help menu hooks -------------------------------------------------------
+
+ def _get_help_menu_actions(self) -> list[QW.QAction | None]:
+ """Insert 'Release notes' just before 'About...'."""
+ actions = super()._get_help_menu_actions()
+ # During super().__init__(), custom actions are still None — skip
+ if self.release_notes_action is None:
+ return actions
+ # Find the "About..." action (last one) and insert before it
+ actions.insert(-1, None)
+ actions.insert(-1, self.release_notes_action)
+ return actions
+
+
+# =============================================================================
+# Helpers
+# =============================================================================
+
+
+def _get_action_texts(menu: QW.QMenu) -> list[str | None]:
+ """Return action texts from a menu (None for separators)."""
+ result: list[str | None] = []
+ for action in menu.actions():
+ if action.isSeparator():
+ result.append(None)
+ else:
+ result.append(action.text())
+ return result
+
+
+# =============================================================================
+# Test
+# =============================================================================
+
+
+def test_menu_hooks():
+ """Verify that menu hook overrides produce the expected menu layout."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = CustomMenuWindow()
+ win.resize(1000, 600)
+ win.show()
+
+ # -- Trigger file menu rebuild (simulates aboutToShow) ----------------
+ win._update_file_menu() # pylint: disable=protected-access
+ file_texts = _get_action_texts(win.file_menu)
+
+ # "New project" must be first real action (after leading separator)
+ assert _("New project") in file_texts, f"Missing 'New project': {file_texts}"
+
+ # "Import CSV..." must be present
+ assert _("Import CSV...") in file_texts, f"Missing 'Import CSV': {file_texts}"
+
+ # "Web API status" must be near the end (added by _update_file_menu)
+ assert _("Web API status") in file_texts, (
+ f"Missing 'Web API status': {file_texts}"
+ )
+
+ # "New project" before HDF5 actions
+ idx_new = file_texts.index(_("New project"))
+ idx_open = file_texts.index(_("Open HDF5 files..."))
+ assert idx_new < idx_open, "New project should appear before Open HDF5"
+
+ # "Import CSV..." between browse and settings
+ idx_csv = file_texts.index(_("Import CSV..."))
+ idx_browse = file_texts.index(_("Browse HDF5 file..."))
+ assert idx_csv > idx_browse, "Import CSV should appear after Browse HDF5"
+
+ # Save should be disabled
+ assert not win.saveh5_action.isEnabled(), "Save should be disabled"
+
+ # -- Trigger view menu rebuild ----------------------------------------
+ win._update_view_menu() # pylint: disable=protected-access
+ view_texts = _get_action_texts(win.view_menu)
+
+ assert _("Preferences...") in view_texts, f"Missing 'Preferences': {view_texts}"
+ # Preferences should be last real action
+ real_actions = [t for t in view_texts if t is not None]
+ assert real_actions[-1] == _("Preferences...")
+
+ # -- Verify help menu (built once at construction) --------------------
+ help_texts = _get_action_texts(win.help_menu)
+
+ assert _("Release notes") in help_texts, (
+ f"Missing 'Release notes': {help_texts}"
+ )
+ assert _("About...") in help_texts, f"Missing 'About': {help_texts}"
+
+ # "Release notes" must appear before "About..."
+ idx_rn = help_texts.index(_("Release notes"))
+ idx_about = help_texts.index(_("About..."))
+ assert idx_rn < idx_about, "Release notes should appear before About"
+
+ # -- Clean close ------------------------------------------------------
+ win.set_modified(False)
+ win.close()
+
+ print("Menu hooks test passed.")
+
+
+if __name__ == "__main__":
+ test_menu_hooks()
diff --git a/sigimax/tests/mainwindow/test_toolbar_hooks.py b/sigimax/tests/mainwindow/test_toolbar_hooks.py
new file mode 100644
index 0000000..df874e7
--- /dev/null
+++ b/sigimax/tests/mainwindow/test_toolbar_hooks.py
@@ -0,0 +1,221 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Toolbar hooks functional test
+------------------------------
+
+Verify that a derived application can fully customize the main toolbar
+by overriding ``_create_global_actions()`` and ``_get_main_toolbar_actions()``
+hooks provided by :class:`SGMXMainWindow`.
+
+The test builds a minimal derived window that:
+
+- Adds a custom "Settings" action via ``_create_global_actions``.
+- Reorders toolbar actions and inserts a separator via
+ ``_get_main_toolbar_actions``.
+- Verifies toolbar content matches the expected layout.
+- Verifies that default H5 actions are still present and functional.
+"""
+
+# guitest: show
+
+from __future__ import annotations
+
+import pytest
+from guidata.configtools import get_icon
+from guidata.qthelpers import create_action
+from qtpy import QtWidgets as QW
+
+from sigimax.config import CONF as Conf
+from sigimax.config import _
+from sigimax.mainwindow import SGMXMainWindow
+from sigimax.utils import qthelpers as qth
+
+pytestmark = pytest.mark.app
+
+# =============================================================================
+# Derived window with custom toolbar hooks
+# =============================================================================
+
+
+class CustomToolbarWindow(SGMXMainWindow):
+ """Derived window exercising toolbar action hooks."""
+
+ def __init__(self) -> None:
+ Conf.app_name.set("ToolbarHookTest")
+ Conf.app_version.set("0.0.1")
+
+ self.settings_action: QW.QAction | None = None
+ self.import_csv_action: QW.QAction | None = None
+
+ super().__init__(console=False)
+
+ # -- Global action hooks ---------------------------------------------------
+
+ def _create_global_actions(self) -> None:
+ """Create default actions, then add custom ones."""
+ super()._create_global_actions()
+
+ self.settings_action = create_action(
+ self,
+ _("Settings..."),
+ icon=get_icon("libre-gui-settings.svg"),
+ tip=_("Open settings dialog"),
+ )
+ self.import_csv_action = create_action(
+ self,
+ _("Import CSV..."),
+ icon=get_icon("fileopen_signal.svg"),
+ tip=_("Import data from a CSV file"),
+ )
+
+ def _get_main_toolbar_actions(self) -> list[QW.QAction | None]:
+ """Custom toolbar: open, save, browse, separator, import CSV, settings."""
+ return [
+ self.openh5_action,
+ self.saveh5_action,
+ self.browseh5_action,
+ None, # separator
+ self.import_csv_action,
+ None, # separator
+ self.settings_action,
+ ]
+
+
+# =============================================================================
+# Derived window that removes H5 actions from toolbar
+# =============================================================================
+
+
+class MinimalToolbarWindow(SGMXMainWindow):
+ """Derived window with a minimal toolbar (no H5 actions)."""
+
+ def __init__(self) -> None:
+ Conf.app_name.set("MinimalToolbarTest")
+ Conf.app_version.set("0.0.1")
+
+ self.custom_action: QW.QAction | None = None
+
+ super().__init__(console=False)
+
+ def _create_global_actions(self) -> None:
+ """Create default actions plus a single custom action."""
+ super()._create_global_actions()
+
+ self.custom_action = create_action(
+ self,
+ _("My Action"),
+ tip=_("A custom action"),
+ )
+
+ def _get_main_toolbar_actions(self) -> list[QW.QAction | None]:
+ """Only show the custom action in the toolbar."""
+ return [self.custom_action]
+
+
+# =============================================================================
+# Helpers
+# =============================================================================
+
+
+def _get_toolbar_action_texts(toolbar: QW.QToolBar) -> list[str | None]:
+ """Return action texts from a toolbar (None for separators)."""
+ result: list[str | None] = []
+ for action in toolbar.actions():
+ if action.isSeparator():
+ result.append(None)
+ else:
+ result.append(action.text())
+ return result
+
+
+# =============================================================================
+# Test
+# =============================================================================
+
+
+def test_toolbar_hooks_custom():
+ """Verify that toolbar hook overrides produce the expected toolbar layout."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = CustomToolbarWindow()
+ win.resize(1000, 600)
+ win.show()
+
+ texts = _get_toolbar_action_texts(win.main_toolbar)
+
+ # -- Default H5 actions must still be present -------------------------
+ assert _("Open HDF5 files...") in texts, f"Missing Open HDF5: {texts}"
+ assert _("Save to HDF5 file...") in texts, f"Missing Save HDF5: {texts}"
+ assert _("Browse HDF5 file...") in texts, f"Missing Browse HDF5: {texts}"
+
+ # -- Custom actions must be present -----------------------------------
+ assert _("Import CSV...") in texts, f"Missing Import CSV: {texts}"
+ assert _("Settings...") in texts, f"Missing Settings: {texts}"
+
+ # -- Separators must be present (at least 2) --------------------------
+ sep_count = texts.count(None)
+ assert sep_count >= 2, f"Expected at least 2 separators, got {sep_count}"
+
+ # -- Order: Open < Save < Browse < separator < Import CSV < separator < Settings
+ idx_open = texts.index(_("Open HDF5 files..."))
+ idx_save = texts.index(_("Save to HDF5 file..."))
+ idx_browse = texts.index(_("Browse HDF5 file..."))
+ idx_csv = texts.index(_("Import CSV..."))
+ idx_settings = texts.index(_("Settings..."))
+
+ assert idx_open < idx_save < idx_browse, (
+ f"H5 actions out of order: {idx_open}, {idx_save}, {idx_browse}"
+ )
+ assert idx_browse < idx_csv < idx_settings, (
+ f"Custom actions out of order: {idx_browse}, {idx_csv}, {idx_settings}"
+ )
+
+ # -- H5 actions are still usable (not None) --------------------------
+ assert win.openh5_action is not None
+ assert win.saveh5_action is not None
+ assert win.browseh5_action is not None
+
+ # -- Clean close ------------------------------------------------------
+ win.set_modified(False)
+ win.close()
+
+ print("Toolbar hooks (custom) test passed.")
+
+
+def test_toolbar_hooks_minimal():
+ """Verify that a derived app can replace the toolbar entirely."""
+ with qth.sigimax_app_context(exec_loop=False):
+ win = MinimalToolbarWindow()
+ win.resize(800, 500)
+ win.show()
+
+ texts = _get_toolbar_action_texts(win.main_toolbar)
+
+ # -- Only the custom action should be in the toolbar ------------------
+ real_actions = [t for t in texts if t is not None]
+ assert real_actions == [_("My Action")], (
+ f"Expected only 'My Action', got: {real_actions}"
+ )
+
+ # -- H5 actions should still exist (just not in toolbar) --------------
+ assert win.openh5_action is not None, "openh5_action should still be created"
+ assert win.saveh5_action is not None, "saveh5_action should still be created"
+ assert win.browseh5_action is not None, (
+ "browseh5_action should still be created"
+ )
+
+ # -- H5 actions are NOT in the toolbar --------------------------------
+ assert _("Open HDF5 files...") not in texts, (
+ "Open HDF5 should NOT be in minimal toolbar"
+ )
+
+ # -- Clean close ------------------------------------------------------
+ win.set_modified(False)
+ win.close()
+
+ print("Toolbar hooks (minimal) test passed.")
+
+
+if __name__ == "__main__":
+ test_toolbar_hooks_custom()
+ test_toolbar_hooks_minimal()
diff --git a/sigimax/tests/test_env.py b/sigimax/tests/test_env.py
new file mode 100644
index 0000000..88f6de8
--- /dev/null
+++ b/sigimax/tests/test_env.py
@@ -0,0 +1,137 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for env.py execution environment
+---------------------------------------
+
+Covers:
+- VerbosityLevels enum values
+- SGMXExecEnv.print with different verbosity levels
+- SGMXExecEnv.pprint output
+- SGMXExecEnv.to_dict
+- SGMXExecEnv.context manager
+"""
+
+from __future__ import annotations
+
+import io
+
+import pytest
+
+from sigimax.env import VerbosityLevels, execenv
+
+pytestmark = pytest.mark.unit
+
+
+class TestVerbosityLevels:
+ """Tests for VerbosityLevels enum."""
+
+ def test_quiet_value(self):
+ """The QUIET level should have the value 'quiet'."""
+ assert VerbosityLevels.QUIET.value == "quiet"
+
+ def test_normal_value(self):
+ """The NORMAL level should have the value 'normal'."""
+ assert VerbosityLevels.NORMAL.value == "normal"
+
+ def test_debug_value(self):
+ """The DEBUG level should have the value 'debug'."""
+ assert VerbosityLevels.DEBUG.value == "debug"
+
+ def test_all_values(self):
+ """All enum values should be present and correct."""
+ values = {v.value for v in VerbosityLevels}
+ assert values == {"quiet", "normal", "debug"}
+
+
+class TestSGMXExecEnv:
+ """Tests for the SGMXExecEnv singleton behavior."""
+
+ def test_to_dict_returns_dict(self):
+ """to_dict should return a dictionary with key properties."""
+ d = execenv.to_dict()
+ assert isinstance(d, dict)
+ # Should contain at least the key properties
+ assert "unattended" in d
+ assert "verbose" in d
+
+ def test_print_normal_verbosity(self):
+ """In normal verbosity, print() should output."""
+ old_verbose = execenv.verbose
+ try:
+ execenv.verbose = VerbosityLevels.NORMAL.value
+ buf = io.StringIO()
+ execenv.print("test output", file=buf)
+ assert "test output" in buf.getvalue()
+ finally:
+ execenv.verbose = old_verbose
+
+ def test_print_quiet_suppresses(self):
+ """In quiet verbosity, print() should suppress output."""
+ old_verbose = execenv.verbose
+ try:
+ execenv.verbose = VerbosityLevels.QUIET.value
+ buf = io.StringIO()
+ execenv.print("should not appear", file=buf)
+ assert buf.getvalue() == ""
+ finally:
+ execenv.verbose = old_verbose
+
+ def test_pprint_normal_verbosity(self):
+ """In normal verbosity, pprint() should produce output."""
+ old_verbose = execenv.verbose
+ try:
+ execenv.verbose = VerbosityLevels.NORMAL.value
+ buf = io.StringIO()
+ execenv.pprint({"key": "value"}, stream=buf)
+ assert "key" in buf.getvalue()
+ finally:
+ execenv.verbose = old_verbose
+
+ def test_pprint_quiet_suppresses(self):
+ """In quiet verbosity, pprint() should suppress output."""
+ old_verbose = execenv.verbose
+ try:
+ execenv.verbose = VerbosityLevels.QUIET.value
+ buf = io.StringIO()
+ execenv.pprint({"key": "value"}, stream=buf)
+ assert buf.getvalue() == ""
+ finally:
+ execenv.verbose = old_verbose
+
+ def test_context_manager_restores(self):
+ """Context manager should restore previous state on exit."""
+ old_unattended = execenv.unattended
+ old_verbose = execenv.verbose
+ with execenv.context(unattended=True, verbose="debug"):
+ assert execenv.unattended is True
+ assert execenv.verbose == "debug"
+ assert execenv.unattended == old_unattended
+ assert execenv.verbose == old_verbose
+
+ def test_str_representation(self):
+ """__str__ should return a non-empty string."""
+ s = str(execenv)
+ assert len(s) > 0
+
+ def test_demo_mode(self):
+ """enable_demo_mode / disable_demo_mode toggle."""
+ old_unattended = execenv.unattended
+ old_delay = execenv.delay
+ try:
+ execenv.enable_demo_mode(delay=500)
+ assert execenv.demo_mode is True
+ assert execenv.unattended is True
+ assert execenv.delay == 500
+
+ execenv.disable_demo_mode()
+ assert execenv.demo_mode is False
+ assert execenv.unattended is False
+ assert execenv.delay == 0
+ finally:
+ execenv.unattended = old_unattended
+ execenv.delay = old_delay
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/utils/__init__.py b/sigimax/tests/utils/__init__.py
new file mode 100644
index 0000000..f95cbf8
--- /dev/null
+++ b/sigimax/tests/utils/__init__.py
@@ -0,0 +1 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
diff --git a/sigimax/tests/utils/test_qthelpers.py b/sigimax/tests/utils/test_qthelpers.py
new file mode 100644
index 0000000..9c07a7c
--- /dev/null
+++ b/sigimax/tests/utils/test_qthelpers.py
@@ -0,0 +1,191 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for utils/qthelpers.py
+-----------------------------
+
+Covers:
+- get_log_contents, initialize_log_file, remove_empty_log_file (unit)
+- is_running_tests (unit)
+- save_restore_stds (unit)
+- block_signals (gui)
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+import tempfile
+
+import pytest
+from guidata.qthelpers import qt_app_context
+from qtpy import QtWidgets as QW
+
+from sigimax.utils.qthelpers import (
+ block_signals,
+ get_log_contents,
+ initialize_log_file,
+ is_running_tests,
+ remove_empty_log_file,
+ save_restore_stds,
+)
+
+# ======================== Unit tests =========================================
+
+pytestmark = pytest.mark.unit
+
+
+class TestGetLogContents:
+ """Tests for get_log_contents."""
+
+ def test_nonexistent_file_returns_none(self):
+ """Should return None for a nonexistent file."""
+ assert get_log_contents("/nonexistent/path/file.log") is None
+
+ def test_empty_file_returns_empty(self):
+ """Should return empty string for an empty file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f:
+ path = f.name
+ try:
+ result = get_log_contents(path)
+ # Empty file → empty string (stripped)
+ assert result == ""
+ finally:
+ os.unlink(path)
+
+ def test_file_with_content(self):
+ """Should return the file contents as a string."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".log", delete=False, encoding="utf-8"
+ ) as f:
+ f.write("error occurred at line 42\n")
+ path = f.name
+ try:
+ result = get_log_contents(path)
+ assert "error occurred" in result
+ finally:
+ os.unlink(path)
+
+
+class TestInitializeLogFile:
+ """Tests for initialize_log_file."""
+
+ def test_no_previous_log(self):
+ """Should initialize log file when no previous log exists (empty file)."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f:
+ path = f.name
+ try:
+ result = initialize_log_file(path)
+ assert result is False # Empty file → no previous log
+ finally:
+ if os.path.exists(path):
+ os.unlink(path)
+
+ def test_with_previous_log(self):
+ """Should initialize and rename previous log file."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".log", delete=False, encoding="utf-8"
+ ) as f:
+ f.write("some log content\n")
+ path = f.name
+ old_path = os.path.splitext(path)[0] + ".1.log"
+ try:
+ result = initialize_log_file(path)
+ assert result is True
+ assert os.path.exists(old_path)
+ finally:
+ for p in (path, old_path):
+ if os.path.exists(p):
+ os.unlink(p)
+
+
+class TestRemoveEmptyLogFile:
+ """Tests for remove_empty_log_file."""
+
+ def test_removes_empty_file(self):
+ """Should remove an empty log file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f:
+ path = f.name
+ remove_empty_log_file(path)
+ assert not os.path.exists(path)
+
+ def test_keeps_nonempty_file(self):
+ """Should not remove a file that has content."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".log", delete=False, encoding="utf-8"
+ ) as f:
+ f.write("content\n")
+ path = f.name
+ try:
+ remove_empty_log_file(path)
+ assert os.path.exists(path)
+ finally:
+ os.unlink(path)
+
+
+class TestIsRunningTests:
+ """Tests for is_running_tests."""
+
+ def test_returns_true_during_pytest(self):
+ """Should return True when running under pytest."""
+ assert is_running_tests() is True
+
+ def test_pytest_in_modules(self):
+ """Should have pytest in sys.modules during tests."""
+ assert "pytest" in sys.modules
+
+
+class TestSaveRestoreStds:
+ """Tests for save_restore_stds context manager."""
+
+ def test_restores_stdout(self):
+ """Should restore original stdout after context."""
+ original_stdout = sys.stdout
+ with save_restore_stds():
+ assert sys.stdout is None
+ assert sys.stdout is original_stdout
+
+ def test_restores_stderr(self):
+ """Should restore original stderr after context."""
+ original_stderr = sys.stderr
+ with save_restore_stds():
+ pass # stdout is None inside
+ assert sys.stderr is original_stderr
+
+ def test_restores_on_exception(self):
+ """Should restore even if an exception is raised inside the context."""
+ original_stdout = sys.stdout
+ try:
+ with save_restore_stds():
+ raise RuntimeError("test")
+ except RuntimeError:
+ pass
+ assert sys.stdout is original_stdout
+
+
+# ======================== GUI tests ==========================================
+
+
+@pytest.mark.gui
+def test_block_signals():
+ """block_signals context manager blocks and unblocks signals."""
+ with qt_app_context():
+ widget = QW.QLineEdit()
+ assert not widget.signalsBlocked()
+ with block_signals(widget):
+ assert widget.signalsBlocked()
+ assert not widget.signalsBlocked()
+
+
+@pytest.mark.gui
+def test_block_signals_disabled():
+ """block_signals with enable=False should not block."""
+ with qt_app_context():
+ widget = QW.QLineEdit()
+ with block_signals(widget, enable=False):
+ assert not widget.signalsBlocked()
+ assert not widget.signalsBlocked()
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/widgets/__init__.py b/sigimax/tests/widgets/__init__.py
new file mode 100644
index 0000000..792d600
--- /dev/null
+++ b/sigimax/tests/widgets/__init__.py
@@ -0,0 +1 @@
+#
diff --git a/sigimax/tests/widgets/_logview_error.py b/sigimax/tests/widgets/_logview_error.py
new file mode 100644
index 0000000..6212b78
--- /dev/null
+++ b/sigimax/tests/widgets/_logview_error.py
@@ -0,0 +1,24 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Log viewer test: raise an exception and create a seg fault in DataLab
+"""
+
+# guitest: skip
+
+from guidata.qthelpers import qt_app_context
+
+from sigimax.env import execenv
+from sigimax.mainwindow import SGMXMainWindow
+
+
+def error():
+ """Raise an exception and create a seg fault in DataLab"""
+ with execenv.context(unattended=True):
+ with qt_app_context(exec_loop=True):
+ win = SGMXMainWindow()
+ win.test_segfault_error()
+
+
+if __name__ == "__main__":
+ error()
diff --git a/sigimax/tests/widgets/test_background_dialog.py b/sigimax/tests/widgets/test_background_dialog.py
new file mode 100644
index 0000000..5f6872b
--- /dev/null
+++ b/sigimax/tests/widgets/test_background_dialog.py
@@ -0,0 +1,73 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Image background dialog unit test.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# pylint: disable=duplicate-code
+# guitest: show
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+import sigima.objects
+import sigima.params
+import sigima.proc.image as sipi
+from guidata.qthelpers import exec_dialog, qt_app_context
+from sigima import viz
+from sigima.tests.data import create_noisy_gaussian_image
+
+from sigimax.env import execenv
+from sigimax.widgets.imagebackground import ImageBackgroundDialog
+
+pytestmark = pytest.mark.gui
+
+
+def test_image_background_selection() -> None:
+ """Image background selection test."""
+ with qt_app_context():
+ img = create_noisy_gaussian_image()
+ # Switch to non-uniform coordinates to test the background dialog handling:
+ xcoords = np.linspace(0, 10, img.data.shape[1])
+ img.set_coords(xcoords, 0.02 * xcoords**3)
+ dlg = ImageBackgroundDialog(img)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix
+ exec_dialog(dlg)
+ if execenv.unattended:
+ dlg.test_compute_background()
+ execenv.print(f"background: {dlg.get_background()}")
+ execenv.print(f"rect coords: {dlg.get_rect_coords()}")
+ # Check background value:
+ x0, y0, x1, y1 = dlg.get_rect_coords()
+ ix0, iy0, ix1, iy1 = dlg.imageitem.get_closest_index_rect(x0, y0, x1, y1)
+ assert np.isclose(img.data[iy0:iy1, ix0:ix1].mean(), dlg.get_background())
+
+
+def test_image_offset_correction_with_background_dialog() -> None:
+ """Image offset correction interactive test using the background dialog."""
+ with qt_app_context():
+ i1 = create_noisy_gaussian_image()
+ dlg = ImageBackgroundDialog(i1)
+ ok = exec_dialog(dlg)
+ if ok:
+ if execenv.unattended:
+ dlg.test_compute_background()
+ param = sigima.objects.ROI2DParam()
+ # pylint: disable=unbalanced-tuple-unpacking
+ ix0, iy0, ix1, iy1 = i1.physical_to_indices(dlg.get_rect_coords())
+ param.x0, param.y0, param.dx, param.dy = ix0, iy0, ix1 - ix0, iy1 - iy0
+ i2 = sipi.offset_correction(i1, param)
+ i3 = sipi.clip(i2, sigima.params.ClipParam.create(lower=0))
+ viz.view_images_side_by_side(
+ [i1, i3],
+ titles=["Original image", "Corrected image"],
+ title="Image offset correction and thresholding",
+ )
+
+
+if __name__ == "__main__":
+ test_image_background_selection()
+ test_image_offset_correction_with_background_dialog()
diff --git a/sigimax/tests/widgets/test_baseline_dialog.py b/sigimax/tests/widgets/test_baseline_dialog.py
new file mode 100644
index 0000000..7bbe5d7
--- /dev/null
+++ b/sigimax/tests/widgets/test_baseline_dialog.py
@@ -0,0 +1,56 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Baseline dialog test
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# pylint: disable=duplicate-code
+# guitest: show
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+import sigima.objects
+import sigima.proc.signal as sips
+from guidata.qthelpers import exec_dialog, qt_app_context
+from sigima.tests.data import create_paracetamol_signal
+from sigima.viz import view_curves
+
+from sigimax.env import execenv
+from sigimax.widgets.signalbaseline import SignalBaselineDialog
+
+pytestmark = pytest.mark.gui
+
+
+def test_signal_baseline_selection():
+ """Signal baseline selection dialog test"""
+ sig = create_paracetamol_signal()
+ with qt_app_context():
+ dlg = SignalBaselineDialog(sig)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix
+ exec_dialog(dlg)
+ execenv.print(f"baseline: {dlg.get_baseline()}")
+ execenv.print(f"X range: {dlg.get_x_range()}")
+ # Check baseline value:
+ i0, i1 = np.searchsorted(sig.x, dlg.get_x_range())
+ assert dlg.get_baseline() == sig.data[i0:i1].mean()
+
+
+def test_signal_baseline_dialog() -> None:
+ """Test the signal baseline dialog for offset correction."""
+ with qt_app_context():
+ s1 = create_paracetamol_signal()
+ dlg = SignalBaselineDialog(s1)
+ if exec_dialog(dlg):
+ param = sigima.objects.ROI1DParam()
+ param.xmin, param.xmax = dlg.get_x_range()
+ s2 = sips.offset_correction(s1, param)
+ view_curves([s1, s2], title="Signal offset correction")
+
+
+if __name__ == "__main__":
+ test_signal_baseline_selection()
+ test_signal_baseline_dialog()
diff --git a/sigimax/tests/widgets/test_deltax_dialog.py b/sigimax/tests/widgets/test_deltax_dialog.py
new file mode 100644
index 0000000..0726b11
--- /dev/null
+++ b/sigimax/tests/widgets/test_deltax_dialog.py
@@ -0,0 +1,37 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Signal delta x dialog unit test.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+import pytest
+from guidata.qthelpers import exec_dialog, qt_app_context
+from sigima.tests.data import create_paracetamol_signal
+from sigima.tools.signal.pulse import full_width_at_y
+
+from sigimax.widgets.signaldeltax import SignalDeltaXDialog
+
+pytestmark = pytest.mark.gui
+
+
+def test_signal_delta_x_dialog():
+ """Test the SignalDeltaXDialog widget."""
+ sig = create_paracetamol_signal()
+ with qt_app_context():
+ dlg = SignalDeltaXDialog(signal=sig)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix
+ exec_dialog(dlg)
+ y = dlg.get_y_value()
+ x0, y0, x1, y1 = dlg.get_coords()
+ exp_x0, exp_y0, exp_x1, exp_y1 = full_width_at_y(sig.x, sig.y, y)
+ assert (x0, y0, x1, y1) == (exp_x0, exp_y0, exp_x1, exp_y1), (
+ f"Expected: {(exp_x0, exp_y0, exp_x1, exp_y1)} but got: {(x0, y0, x1, y1)}"
+ )
+
+
+if __name__ == "__main__":
+ test_signal_delta_x_dialog()
diff --git a/sigimax/tests/widgets/test_display_all.py b/sigimax/tests/widgets/test_display_all.py
new file mode 100644
index 0000000..05f2905
--- /dev/null
+++ b/sigimax/tests/widgets/test_display_all.py
@@ -0,0 +1,222 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Display all SigimaX widgets
+----------------------------
+
+This script displays all widgets from :mod:`sigimax.widgets` using data
+implementations found in the test modules :mod:`sigimax.tests.widgets`
+and :mod:`sigimax.tests.hdf5`.
+"""
+
+# guitest: show
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+import numpy as np
+from guidata.qthelpers import exec_dialog, qt_app_context
+from sigima.objects import NormalDistribution1DParam
+from sigima.tests.data import (
+ create_noisy_gaussian_image,
+ create_noisy_signal,
+ create_paracetamol_signal,
+ get_test_signal,
+)
+from sigima.tools.signal.peakdetection import peak_indices
+
+from sigimax.env import execenv
+from sigimax.tests import helpers, sigimax_test_app_context
+from sigimax.tests.hdf5.test_h5browser_app import create_h5browser_dialog
+from sigimax.widgets import fitdialog as fdlg
+from sigimax.widgets.imagebackground import ImageBackgroundDialog
+from sigimax.widgets.logviewer import exec_sigimax_logviewer_dialog
+from sigimax.widgets.signalbaseline import SignalBaselineDialog
+from sigimax.widgets.signalcursor import SignalCursorDialog
+from sigimax.widgets.signaldeltax import SignalDeltaXDialog
+from sigimax.widgets.signalpeak import SignalPeakDetectionDialog
+
+
+def display_signal_baseline_dialog() -> None:
+ """Display the signal baseline selection dialog."""
+ execenv.print("--- SignalBaselineDialog ---")
+ sig = create_paracetamol_signal()
+ dlg = SignalBaselineDialog(sig)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+ execenv.print(f" baseline: {dlg.get_baseline()}")
+ execenv.print(f" X range: {dlg.get_x_range()}")
+
+
+def display_signal_cursor_dialog_horizontal() -> None:
+ """Display the signal cursor dialog in horizontal mode."""
+ execenv.print("--- SignalCursorDialog (horizontal) ---")
+ sig = create_paracetamol_signal()
+ dlg = SignalCursorDialog(signal=sig, cursor_orientation="horizontal")
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+ x, y = dlg.get_cursor_position()
+ execenv.print(f" cursor position: x={x}, y={y}")
+
+
+def display_signal_cursor_dialog_vertical() -> None:
+ """Display the signal cursor dialog in vertical mode."""
+ execenv.print("--- SignalCursorDialog (vertical) ---")
+ sig = create_paracetamol_signal()
+ dlg = SignalCursorDialog(signal=sig, cursor_orientation="vertical")
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+ x, y = dlg.get_cursor_position()
+ execenv.print(f" cursor position: x={x}, y={y}")
+
+
+def display_signal_deltax_dialog() -> None:
+ """Display the signal delta X dialog."""
+ execenv.print("--- SignalDeltaXDialog ---")
+ sig = create_paracetamol_signal()
+ dlg = SignalDeltaXDialog(signal=sig)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+ y = dlg.get_y_value()
+ x0, y0, x1, y1 = dlg.get_coords()
+ execenv.print(f" y={y}, coords=({x0}, {y0}, {x1}, {y1})")
+
+
+def display_signal_peak_detection_dialog() -> None:
+ """Display the signal peak detection dialog."""
+ execenv.print("--- SignalPeakDetectionDialog ---")
+ s = get_test_signal("paracetamol.txt")
+ dlg = SignalPeakDetectionDialog(s)
+ dlg.resize(640, 300)
+ plot = dlg.get_plot()
+ plot.set_axis_limits(plot.xBottom, 16, 30)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+ execenv.print(" peaks:")
+ execenv.pprint(dlg.get_peaks())
+ execenv.print(f" min_dist: {dlg.get_min_dist()}")
+
+
+def display_image_background_dialog() -> None:
+ """Display the image background dialog."""
+ execenv.print("--- ImageBackgroundDialog ---")
+ img = create_noisy_gaussian_image()
+ xcoords = np.linspace(0, 10, img.data.shape[1])
+ img.set_coords(xcoords, 0.02 * xcoords**3)
+ dlg = ImageBackgroundDialog(img)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+ if execenv.unattended:
+ dlg.test_compute_background()
+ execenv.print(f" background: {dlg.get_background()}")
+ execenv.print(f" rect coords: {dlg.get_rect_coords()}")
+
+
+def display_fit_dialogs() -> None:
+ """Display all curve fitting dialogs."""
+ execenv.print("--- Fit Dialogs ---")
+ s1 = get_test_signal("paracetamol.txt")
+ peakidx = peak_indices(s1.y)
+ s2 = create_noisy_signal(NormalDistribution1DParam.create(sigma=5.0))
+ s3 = get_test_signal("gaussian_fit.txt")
+ s4 = get_test_signal("piecewiseexponential_fit.txt")
+
+ ep = execenv.print
+ tn = helpers.get_default_test_name
+
+ ep(" Polynomial fit:")
+ ep(fdlg.polynomial_fit(s2.x, s2.y, 4, name=tn("00")))
+ ep(" Linear fit:")
+ ep(fdlg.linear_fit(s2.x, s2.y, name=tn("01")))
+ ep(" Gaussian fit:")
+ ep(fdlg.gaussian_fit(s3.x, s3.y, name=tn("02")))
+ ep(" Lorentzian fit:")
+ ep(fdlg.lorentzian_fit(s3.x, s3.y, name=tn("03")))
+ ep(" Multi-Gaussian fit:")
+ ep(fdlg.multigaussian_fit(s1.x, s1.y, peakidx, name=tn("04")))
+ ep(" Multi-Lorentzian fit:")
+ ep(fdlg.multilorentzian_fit(s1.x, s1.y, peakidx, name=tn("05")))
+ ep(" Voigt fit:")
+ ep(fdlg.voigt_fit(s3.x, s3.y, name=tn("06")))
+ ep(" Exponential fit:")
+ ep(fdlg.exponential_fit(s2.x, s2.y, name=tn("07")))
+ ep(" Sinusoidal fit:")
+ ep(fdlg.sinusoidal_fit(s2.x, s2.y, name=tn("08")))
+ ep(" CDF fit:")
+ ep(fdlg.cdf_fit(s2.x, s2.y, name=tn("09")))
+ ep(" Planckian fit:")
+ ep(fdlg.planckian_fit(s3.x, s3.y, name=tn("10")))
+ ep(" Two-half Gaussian fit:")
+ ep(fdlg.twohalfgaussian_fit(s3.x, s3.y, name=tn("11")))
+ ep(" Piecewise exponential fit:")
+ ep(fdlg.piecewiseexponential_fit(s4.x, s4.y, name=tn("12")))
+
+
+def display_logviewer_dialog() -> None:
+ """Display the log viewer dialog."""
+ execenv.print("--- LogViewer Dialog ---")
+ exec_sigimax_logviewer_dialog()
+
+
+def display_h5browser_dialog() -> None:
+ """Display the HDF5 browser dialog."""
+ execenv.print("--- H5BrowserDialog ---")
+ fnames = helpers.get_test_fnames("*.h5")[-2:]
+ dlg = create_h5browser_dialog(fnames, toggle_all=True, select_all=True)
+ dlg.setObjectName(dlg.objectName() + "_00")
+ exec_dialog(dlg)
+
+
+def display_memory_status() -> None:
+ """Display the memory status widget in the main window."""
+ execenv.print("--- Memory Status Widget ---")
+ with sigimax_test_app_context(console=False) as win:
+ win.memorystatus.update_status()
+
+
+def display_h5import() -> None:
+ """Display HDF5 import in the main window."""
+ execenv.print("--- HDF5 Import ---")
+ with sigimax_test_app_context(console=False) as win:
+ fnames = helpers.get_test_fnames("*.h5")
+ if fnames:
+ fname = fnames[-1]
+ execenv.print(f" Importing HDF5 file: {fname}")
+ win.import_all_from_h5_file(fname)
+
+
+def display_all_widgets() -> None:
+ """Display all SigimaX widgets with test data."""
+ with qt_app_context():
+ # Signal widgets
+ display_signal_baseline_dialog()
+ display_signal_cursor_dialog_horizontal()
+ display_signal_cursor_dialog_vertical()
+ display_signal_deltax_dialog()
+ display_signal_peak_detection_dialog()
+
+ # Image widgets
+ display_image_background_dialog()
+
+ # Fit dialogs
+ display_fit_dialogs()
+
+ # Log viewer
+ display_logviewer_dialog()
+
+ # HDF5 browser
+ display_h5browser_dialog()
+
+ # Main window widgets (need their own app context)
+ display_memory_status()
+ display_h5import()
+
+
+if __name__ == "__main__":
+ display_all_widgets()
diff --git a/sigimax/tests/widgets/test_fileviewer.py b/sigimax/tests/widgets/test_fileviewer.py
new file mode 100644
index 0000000..b70ecbc
--- /dev/null
+++ b/sigimax/tests/widgets/test_fileviewer.py
@@ -0,0 +1,110 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for fileviewer.py utilities and widget
+--------------------------------------------
+
+Covers:
+- read_text_file: reads UTF-8 and latin1 files
+- get_title_contents: returns (title, contents) tuple
+- FileViewerWidget: basic construction and set_data
+"""
+
+from __future__ import annotations
+
+import os
+import tempfile
+
+import pytest
+from guidata.qthelpers import qt_app_context
+
+from sigimax.widgets.fileviewer import (
+ FileViewerWidget,
+ get_title_contents,
+ read_text_file,
+)
+
+# ======================== Unit tests =========================================
+
+
+class TestReadTextFile:
+ """Unit tests for read_text_file."""
+
+ pytestmark = pytest.mark.unit
+
+ def test_read_utf8(self):
+ """Should read UTF-8 encoded files correctly."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".txt", delete=False, encoding="utf-8"
+ ) as f:
+ f.write("Hello café")
+ path = f.name
+ try:
+ result = read_text_file(path)
+ assert "Hello café" in result
+ finally:
+ os.unlink(path)
+
+ def test_read_latin1(self):
+ """Should read Latin-1 encoded files correctly."""
+ with tempfile.NamedTemporaryFile(mode="wb", suffix=".txt", delete=False) as f:
+ f.write("résumé".encode("latin1"))
+ path = f.name
+ try:
+ result = read_text_file(path)
+ assert "sum" in result # content should be readable
+ finally:
+ os.unlink(path)
+
+ def test_read_ascii(self):
+ """Should read ASCII encoded files correctly."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".txt", delete=False, encoding="ascii"
+ ) as f:
+ f.write("plain ascii")
+ path = f.name
+ try:
+ result = read_text_file(path)
+ assert result == "plain ascii"
+ finally:
+ os.unlink(path)
+
+
+class TestGetTitleContents:
+ """Unit tests for get_title_contents."""
+
+ pytestmark = pytest.mark.unit
+
+ def test_returns_tuple(self):
+ """Should return a (title, contents) tuple."""
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".txt", delete=False, encoding="utf-8"
+ ) as f:
+ f.write("file body")
+ path = f.name
+ try:
+ title, contents = get_title_contents(path)
+ assert isinstance(title, str)
+ assert "file body" in contents
+ assert path in title or os.path.basename(path) in title
+ finally:
+ os.unlink(path)
+
+
+# ======================== GUI tests ==========================================
+
+
+@pytest.mark.gui
+def test_file_viewer_widget():
+ """FileViewerWidget: construct and set data without crashing."""
+ with qt_app_context():
+ widget = FileViewerWidget()
+ widget.set_data("Title text", "Some file contents\nLine 2")
+ assert widget.label.text() == "Title text"
+ assert "Some file contents" in widget.editor.toPlainText()
+ widget.show()
+ widget.close()
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/widgets/test_fitdialog.py b/sigimax/tests/widgets/test_fitdialog.py
new file mode 100644
index 0000000..1d88319
--- /dev/null
+++ b/sigimax/tests/widgets/test_fitdialog.py
@@ -0,0 +1,262 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Curve fitting dialog test
+
+Testing fit dialogs: Gaussian, Lorentzian, Voigt, etc.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+import numpy as np
+import pytest
+from guidata.qthelpers import qt_app_context
+from sigima.objects import NormalDistribution1DParam
+from sigima.tests.data import create_noisy_signal, get_test_signal
+from sigima.tools.signal import fitting, pulse
+from sigima.tools.signal.peakdetection import peak_indices
+
+from sigimax.env import execenv
+from sigimax.tests import helpers
+from sigimax.widgets import fitdialog as fdlg
+
+pytestmark = pytest.mark.gui
+
+
+def check_peak_fit_output(output):
+ """Check versioned interactive peak-fit metadata."""
+ assert output is not None
+ _y_fitted, _params, fit_params = output
+ assert fit_params["fit_params_version"] == 2
+ assert fit_params["peak_parameterization"] == "height"
+ assert fit_params["interactive"] is True
+
+
+def test_fit_dialog():
+ """Test function"""
+ with qt_app_context():
+ # Multi-gaussian curve fitting test
+ s1 = get_test_signal("paracetamol.txt")
+ peakidx = peak_indices(s1.y)
+ s2 = create_noisy_signal(NormalDistribution1DParam.create(sigma=5.0))
+ s3 = get_test_signal("gaussian_fit.txt")
+ s4 = get_test_signal("piecewiseexponential_fit.txt")
+
+ ep = execenv.print
+ tn = helpers.get_default_test_name
+
+ ep(fdlg.polynomial_fit(s2.x, s2.y, 4, name=tn("00")))
+ ep(fdlg.linear_fit(s2.x, s2.y, name=tn("01")))
+ ep(fdlg.gaussian_fit(s3.x, s3.y, name=tn("02")))
+ ep(fdlg.lorentzian_fit(s3.x, s3.y, name=tn("03")))
+ ep(fdlg.multigaussian_fit(s1.x, s1.y, peakidx, name=tn("04")))
+ ep(fdlg.multilorentzian_fit(s1.x, s1.y, peakidx, name=tn("05")))
+ ep(fdlg.voigt_fit(s3.x, s3.y, name=tn("06")))
+ ep(fdlg.exponential_fit(s2.x, s2.y, name=tn("07")))
+ ep(fdlg.sinusoidal_fit(s2.x, s2.y, name=tn("08")))
+ ep(fdlg.cdf_fit(s2.x, s2.y, name=tn("09")))
+ ep(fdlg.planckian_fit(s3.x, s3.y, name=tn("10")))
+ ep(fdlg.twohalfgaussian_fit(s3.x, s3.y, name=tn("11")))
+ ep(fdlg.piecewiseexponential_fit(s4.x, s4.y, name=tn("12")))
+
+
+def test_peak_fit_metadata(monkeypatch):
+ """Peak fit dialogs return canonical metadata when accepted."""
+
+ def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs):
+ return [param.value for param in fitparams]
+
+ monkeypatch.setattr(fdlg, "guifit", accept_initial_values)
+ single_peak = get_test_signal("gaussian_fit.txt")
+ multi_peak = get_test_signal("paracetamol.txt")
+ peakidx = peak_indices(multi_peak.y)
+
+ outputs = (
+ fdlg.gaussian_fit(single_peak.x, single_peak.y),
+ fdlg.lorentzian_fit(single_peak.x, single_peak.y),
+ fdlg.voigt_fit(single_peak.x, single_peak.y),
+ fdlg.multigaussian_fit(multi_peak.x, multi_peak.y, peakidx),
+ fdlg.multilorentzian_fit(multi_peak.x, multi_peak.y, peakidx),
+ )
+ for output in outputs:
+ check_peak_fit_output(output)
+
+
+NON_PEAK_FIT_CASES = (
+ ("linear", "noisy", fdlg.linear_fit),
+ ("polynomial", "noisy", lambda x, y: fdlg.polynomial_fit(x, y, 4)),
+ ("exponential", "noisy", fdlg.exponential_fit),
+ ("sinusoidal", "noisy", fdlg.sinusoidal_fit),
+ ("cdf", "noisy", fdlg.cdf_fit),
+ ("planckian", "gaussian_fit.txt", fdlg.planckian_fit),
+ ("twohalfgaussian", "gaussian_fit.txt", fdlg.twohalfgaussian_fit),
+ (
+ "doubleexponential",
+ "piecewiseexponential_fit.txt",
+ fdlg.piecewiseexponential_fit,
+ ),
+)
+
+
+@pytest.mark.parametrize(("fit_type", "data", "call_dialog"), NON_PEAK_FIT_CASES)
+def test_non_peak_fit_metadata(monkeypatch, fit_type, data, call_dialog):
+ """Non-peak fit dialogs return evaluable canonical metadata.
+
+ The decisive check is the round-trip: re-evaluating the stored parameters
+ with Sigima must reproduce the curve computed by the dialog. It catches any
+ parameter name, ordering or unit mismatch between the two layers.
+ """
+
+ def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs):
+ return [param.value for param in fitparams]
+
+ monkeypatch.setattr(fdlg, "guifit", accept_initial_values)
+ if data == "noisy":
+ signal = create_noisy_signal(NormalDistribution1DParam.create(sigma=5.0))
+ else:
+ signal = get_test_signal(data)
+
+ output = call_dialog(signal.x, signal.y)
+
+ assert output is not None
+ y_fitted, _params, fit_params = output
+ assert fit_params["fit_type"] == fit_type
+ assert fit_params["interactive"] is True
+ fitting.validate_fit_params(fit_params)
+ np.testing.assert_allclose(
+ fitting.evaluate_fit(signal.x, **fit_params), y_fitted, rtol=1e-10, atol=1e-10
+ )
+
+
+@pytest.mark.parametrize(
+ ("dialog", "fit_type"),
+ [
+ (fdlg.multigaussian_fit, "multigaussian"),
+ (fdlg.multilorentzian_fit, "multilorentzian"),
+ ],
+)
+def test_multi_peak_fit_metadata_preserves_fixed_centers(monkeypatch, dialog, fit_type):
+ """Multi-peak metadata preserves centers without adding fit controls."""
+
+ def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs):
+ values = [param.value for param in fitparams]
+ values[1] = -abs(values[1])
+ return values
+
+ monkeypatch.setattr(fdlg, "guifit", accept_initial_values)
+ signal = get_test_signal("paracetamol.txt")
+ peakidx = peak_indices(signal.y)
+
+ output = dialog(signal.x, signal.y, peakidx)
+
+ assert output is not None
+ y_fitted, params, fit_params = output
+ assert len(params) == 2 * len(peakidx) + 1
+ assert fit_params["fit_type"] == fit_type
+ for index, peak_index in enumerate(peakidx, start=1):
+ assert fit_params[f"x0_{index}"] == pytest.approx(signal.x[peak_index])
+ assert fit_params[f"sigma_{index}"] > 0.0
+ np.testing.assert_allclose(fitting.evaluate_fit(signal.x, **fit_params), y_fitted)
+
+
+@pytest.mark.parametrize(
+ ("dialog", "model"),
+ [
+ (fdlg.gaussian_fit, pulse.GaussianModel),
+ (fdlg.lorentzian_fit, pulse.LorentzianModel),
+ (fdlg.voigt_fit, pulse.VoigtModel),
+ ],
+)
+def test_peak_fit_dialog_supports_negative_amplitude(monkeypatch, dialog, model):
+ """Interactive peak controls expose and preserve signed amplitudes."""
+ captured_amplitudes = []
+
+ def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs):
+ captured_amplitudes.append(fitparams[0])
+ return [param.value for param in fitparams]
+
+ monkeypatch.setattr(fdlg, "guifit", accept_initial_values)
+ x = np.linspace(-10.0, 10.0, 400)
+ y = model.evaluate(x, -3.0, 1.5, 0.75, 2.0)
+
+ output = dialog(x, y)
+
+ assert output is not None
+ _y_fitted, _params, fit_params = output
+ assert fit_params["amplitude"] < 0.0
+ amplitude_param = captured_amplitudes[0]
+ assert amplitude_param.min < 0.0 < amplitude_param.max
+
+
+def __capture_fit_params(monkeypatch) -> list:
+ """Patch `guifit` so it accepts the initial values and records the controls."""
+ captured: list = []
+
+ def accept_initial_values(_x, _y, _fitfunc, fitparams, **_kwargs):
+ captured.extend(fitparams)
+ return [param.value for param in fitparams]
+
+ monkeypatch.setattr(fdlg, "guifit", accept_initial_values)
+ return captured
+
+
+@pytest.mark.parametrize(
+ ("dialog", "make_data", "true_values"),
+ [
+ # A decaying exponential: the B slider used to be restricted to
+ # positive values, so this optimum was unreachable.
+ # Parameter order: (a, b, y0)
+ (
+ fdlg.exponential_fit,
+ lambda x: 3.0 * np.exp(-0.8 * x) + 1.0,
+ {1: -0.8},
+ ),
+ # A descending transition: the amplitude slider used to start at 0.
+ # Parameter order: (amplitude, mu, sigma, baseline)
+ (
+ fdlg.cdf_fit,
+ lambda x: (
+ -2.0 * fitting.CDFFitComputer.evaluate(x, 1.0, 5.0, 1.0, 0.0) + 4.0
+ ),
+ {0: -2.0},
+ ),
+ # A decay-then-rise shape: the rate sliders used to hard-code the
+ # opposite (rise-then-decay) sign convention.
+ # Parameter order: (x_center, a_left, b_left, a_right, b_right, y0)
+ (
+ fdlg.piecewiseexponential_fit,
+ lambda x: np.where(x < 5.0, np.exp(-(x - 5.0)), np.exp(x - 5.0)) + 0.5,
+ {2: -1.0, 4: 1.0},
+ ),
+ ],
+)
+def test_fit_dialog_bounds_contain_the_optimum(
+ monkeypatch, dialog, make_data, true_values
+):
+ """Interactive fit sliders must be able to reach the true parameters.
+
+ Several dialogs used one-sided bounds that excluded a whole family of
+ shapes, or bounds derived from the magnitude of the initial guess, which
+ could invert into an empty interval.
+ """
+ captured = __capture_fit_params(monkeypatch)
+ x = np.linspace(0.0, 10.0, 400)
+
+ assert dialog(x, make_data(x)) is not None
+
+ for param in captured:
+ assert param.min < param.max, f"{param.name}: inverted bounds"
+ assert param.min <= param.value <= param.max, (
+ f"{param.name}: initial value outside its bounds"
+ )
+
+ for index, true_value in true_values.items():
+ param = captured[index]
+ assert param.min <= true_value <= param.max, (
+ f"{param.name}: true value {true_value} is outside the slider range "
+ f"[{param.min}, {param.max}]"
+ )
+
+
+if __name__ == "__main__":
+ test_fit_dialog()
diff --git a/sigimax/tests/widgets/test_logviewer.py b/sigimax/tests/widgets/test_logviewer.py
new file mode 100644
index 0000000..7b7fbaf
--- /dev/null
+++ b/sigimax/tests/widgets/test_logviewer.py
@@ -0,0 +1,24 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Log viewer test
+"""
+
+# guitest: show
+
+import pytest
+from guidata.qthelpers import qt_app_context
+
+from sigimax.widgets.logviewer import exec_sigimax_logviewer_dialog
+
+pytestmark = pytest.mark.gui
+
+
+def test_logviewer_dialog():
+ """Test log viewer window"""
+ with qt_app_context():
+ exec_sigimax_logviewer_dialog()
+
+
+if __name__ == "__main__":
+ test_logviewer_dialog()
diff --git a/sigimax/tests/widgets/test_memstatus.py b/sigimax/tests/widgets/test_memstatus.py
new file mode 100644
index 0000000..e778545
--- /dev/null
+++ b/sigimax/tests/widgets/test_memstatus.py
@@ -0,0 +1,62 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Memory status widget application test
+"""
+
+# guitest: show
+
+import psutil
+import pytest
+
+from sigimax import config
+from sigimax.env import execenv
+from sigimax.tests import sigimax_test_app_context
+
+pytestmark = pytest.mark.app
+
+
+def memory_alarm(threshold, expect_alarm):
+ """Memory alarm test
+
+ Args:
+ threshold: available memory threshold (MB)
+ expect_alarm: True if alarm is expected to trigger
+ """
+ config.CONF.available_memory_threshold.set(threshold)
+ with sigimax_test_app_context() as win:
+ alarm_states = []
+ win.memorystatus.SIG_MEMORY_ALARM.connect(alarm_states.append)
+ win.memorystatus.update_status() # Force memory status update
+ assert len(alarm_states) == 1, "SIG_MEMORY_ALARM should have been emitted once"
+ alarm_fired = alarm_states[0]
+ assert alarm_fired == expect_alarm, (
+ f"Expected alarm={expect_alarm} for threshold={threshold} MB, "
+ f"got alarm={alarm_fired}"
+ )
+ # Verify visual indicators match alarm state
+ if expect_alarm:
+ assert "red" in win.memorystatus.label.styleSheet()
+ else:
+ assert "red" not in win.memorystatus.label.styleSheet()
+ execenv.print(f" Alarm fired: {alarm_fired} (expected: {expect_alarm})")
+
+
+def test_mem_status():
+ """Memory alarm test"""
+ mem_available = psutil.virtual_memory().available // (1024**2)
+ execenv.print(f"Memory status widget test (memory available: {mem_available} MB):")
+ test_cases = (
+ (mem_available * 2, True), # Threshold above available → alarm ON
+ (mem_available - 100, False), # Threshold below available → alarm OFF
+ )
+ for index, (threshold, expect_alarm) in enumerate(test_cases):
+ execenv.print(
+ f" Threshold {index}: {threshold} MB (expect alarm: {expect_alarm})"
+ )
+ memory_alarm(threshold, expect_alarm)
+ config.CONF.reset_to_defaults()
+
+
+if __name__ == "__main__":
+ test_mem_status()
diff --git a/sigimax/tests/widgets/test_select_xy_cursor.py b/sigimax/tests/widgets/test_select_xy_cursor.py
new file mode 100644
index 0000000..8cd3ffa
--- /dev/null
+++ b/sigimax/tests/widgets/test_select_xy_cursor.py
@@ -0,0 +1,48 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Signal horizontal or vertical cursor selection unit test.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+from typing import Literal
+
+import numpy as np
+import pytest
+from guidata.qthelpers import exec_dialog, qt_app_context
+from sigima.tests.data import create_paracetamol_signal
+from sigima.tools.signal.features import find_x_values_at_y
+
+from sigimax.env import execenv
+from sigimax.widgets.signalcursor import SignalCursorDialog
+
+pytestmark = pytest.mark.gui
+
+
+@pytest.mark.parametrize("cursor_orientation", ["horizontal", "vertical"])
+def test_signal_cursor_selection(
+ cursor_orientation: Literal["horizontal", "vertical"],
+) -> None:
+ """Parametrized signal cursor selection unit test."""
+ sig = create_paracetamol_signal()
+ with qt_app_context():
+ dlg = SignalCursorDialog(signal=sig, cursor_orientation=cursor_orientation)
+ dlg.resize(640, 480)
+ dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix
+ exec_dialog(dlg)
+ x, y = dlg.get_cursor_position()
+ if cursor_orientation == "horizontal":
+ execenv.print(f"X value: {x}")
+ x_sig = find_x_values_at_y(sig.x, sig.y, y)[0]
+ assert x == x_sig, f"Expected {x_sig}, got {x}"
+ else:
+ execenv.print(f"Y value: {y}")
+ y_sig = sig.y[np.searchsorted(sig.x, x)]
+ assert y == y_sig, f"Expected {y_sig}, got {y}"
+
+
+if __name__ == "__main__":
+ test_signal_cursor_selection(cursor_orientation="horizontal")
+ test_signal_cursor_selection(cursor_orientation="vertical")
diff --git a/sigimax/tests/widgets/test_signalpeak_dialog.py b/sigimax/tests/widgets/test_signalpeak_dialog.py
new file mode 100644
index 0000000..e0e4d52
--- /dev/null
+++ b/sigimax/tests/widgets/test_signalpeak_dialog.py
@@ -0,0 +1,36 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Signal peak detection dialog test.
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+# guitest: show
+
+import pytest
+from guidata.qthelpers import exec_dialog, qt_app_context
+from sigima.tests.data import get_test_signal
+
+from sigimax.env import execenv
+from sigimax.widgets.signalpeak import SignalPeakDetectionDialog
+
+pytestmark = pytest.mark.gui
+
+
+def test_peak1d_dialog():
+ """Signal peak dialog test"""
+ with qt_app_context():
+ s = get_test_signal("paracetamol.txt")
+ dlg = SignalPeakDetectionDialog(s)
+ dlg.resize(640, 300)
+ plot = dlg.get_plot()
+ plot.set_axis_limits(plot.xBottom, 16, 30)
+ dlg.setObjectName(dlg.objectName() + "_00") # to avoid timestamp suffix
+ exec_dialog(dlg)
+ execenv.print("peaks:")
+ execenv.pprint(dlg.get_peaks())
+ execenv.pprint(dlg.get_min_dist())
+
+
+if __name__ == "__main__":
+ test_peak1d_dialog()
diff --git a/sigimax/tests/widgets/test_splashscreen_resource.py b/sigimax/tests/widgets/test_splashscreen_resource.py
new file mode 100644
index 0000000..3f5dd73
--- /dev/null
+++ b/sigimax/tests/widgets/test_splashscreen_resource.py
@@ -0,0 +1,61 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Splash-screen resource resolution tests."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from qtpy import QtGui as QG
+
+from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig
+
+
+def test_splash_resolves_image_basename(tmp_path) -> None:
+ """A basename may be resolved through guidata's registered image paths."""
+ image_path = tmp_path / "derived-splash.png"
+ pixmap = QG.QPixmap(64, 32)
+ pixmap.fill(QG.QColor("red"))
+ assert pixmap.save(str(image_path))
+
+ config = SplashScreenConfig(
+ image_path="derived-splash.png",
+ show_progress=False,
+ )
+ with patch(
+ "sigimax.widgets.splashscreen.get_image_file_path",
+ return_value=str(image_path),
+ ):
+ splash = SigimaXSplashScreen(config)
+
+ assert splash.pixmap().size() == pixmap.size()
+ splash.close()
+
+
+def test_missing_splash_resource_uses_fallback() -> None:
+ """An unresolved configured image must not prevent application startup."""
+ config = SplashScreenConfig(
+ image_path="missing-derived-splash.png",
+ app_name="DerivedApp",
+ )
+ with patch(
+ "sigimax.widgets.splashscreen.get_image_file_path",
+ side_effect=RuntimeError("not found"),
+ ):
+ splash = SigimaXSplashScreen(config)
+
+ assert not splash.pixmap().isNull()
+ assert splash.pixmap().size().width() == 480
+ assert splash.pixmap().size().height() == 280
+ splash.close()
+
+
+def test_progress_message_may_be_disabled() -> None:
+ """Derived apps may preserve an image-only splash without messages."""
+ config = SplashScreenConfig(image_path=None, show_progress=False)
+ splash = SigimaXSplashScreen(config)
+ with patch.object(splash, "showMessage") as show_message:
+ splash.show_message("Initializing...")
+
+ show_message.assert_not_called()
+ splash.close()
diff --git a/sigimax/tests/widgets/test_warningerror.py b/sigimax/tests/widgets/test_warningerror.py
new file mode 100644
index 0000000..560584e
--- /dev/null
+++ b/sigimax/tests/widgets/test_warningerror.py
@@ -0,0 +1,98 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Tests for warningerror.py utilities
+------------------------------------
+
+Covers:
+- insert_spaces: pure text utility
+- WarningErrorMessageBox: basic construction with sample error/warning
+"""
+
+from __future__ import annotations
+
+import pytest
+from guidata.qthelpers import exec_dialog, qt_app_context
+from qtpy import QtWidgets as QW
+
+from sigimax.widgets.warningerror import WarningErrorMessageBox, insert_spaces
+
+pytestmark = pytest.mark.unit
+
+
+class TestInsertSpaces:
+ """Tests for the insert_spaces pure-text utility."""
+
+ def test_short_text_unchanged(self):
+ """
+ Short text should be returned unchanged
+ (except for a possible trailing space).
+ """
+ result = insert_spaces("hi", 80)
+ # Short text should pass through with at most a trailing space
+ assert "hi" in result
+
+ def test_long_text_gets_spaces(self):
+ """Long text should have spaces inserted."""
+ text = "a" * 200
+ result = insert_spaces(text, 40)
+ # Should contain spaces breaking up the text
+ assert " " in result
+ # The content characters should all still be present
+ assert result.replace(" ", "") == text
+
+ def test_special_chars_trigger_break(self):
+ """Special chars should trigger breaks even if text is short."""
+ text = "hello,world-foo+bar"
+ result = insert_spaces(text, 5)
+ assert " " in result
+
+ def test_empty_string(self):
+ """Empty string should return empty string."""
+ result = insert_spaces("", 10)
+ assert result == ""
+
+ def test_exact_nbchars(self):
+ """Text with exactly nbchars should get a space added."""
+ text = "abcde"
+ result = insert_spaces(text, 5)
+ # With exactly nbchars, one iteration adds space
+ assert "abcde" in result
+
+
+def _show_message_box(category: str) -> None:
+ """Construct and show a WarningErrorMessageBox for the given category."""
+ with qt_app_context():
+ win = QW.QMainWindow()
+ win.setWindowTitle(f"SigimaX {category.capitalize()} Message Box test")
+ win.show()
+ if category == "error":
+ try:
+ raise ValueError("Test error message box")
+ except ValueError:
+ context = "Test_error_message_box." * 5
+ tip = "This error may occured when testing the error message box. " * 10
+ dlg = WarningErrorMessageBox(win, "error", context, tip=tip)
+ exec_dialog(dlg)
+ else:
+ context = "Test_warning_message_box." * 5
+ message = "Test warning message box" * 10
+ dlg = WarningErrorMessageBox(win, "warning", context, message)
+ exec_dialog(dlg)
+
+
+@pytest.mark.gui
+class TestWarningErrorMessageBox:
+ """Tests for the WarningErrorMessageBox dialog construction."""
+
+ def test_error_message_box(self):
+ """An error message box can be constructed and shown."""
+ _show_message_box("error")
+
+ def test_warning_message_box(self):
+ """A warning message box can be constructed and shown."""
+ _show_message_box("warning")
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/tests/widgets/test_wizard.py b/sigimax/tests/widgets/test_wizard.py
new file mode 100644
index 0000000..960a8fc
--- /dev/null
+++ b/sigimax/tests/widgets/test_wizard.py
@@ -0,0 +1,172 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+# pylint: disable=protected-access
+
+"""
+Tests for the Wizard widget (:mod:`sigimax.widgets.wizard`)
+-----------------------------------------------------------
+
+Covers:
+- WizardPage: title, subtitle, validity flag, add_to_layout
+- Wizard: page navigation (next/back), button states, accept/reject
+"""
+
+from __future__ import annotations
+
+import pytest
+from guidata.qthelpers import qt_app_context
+from qtpy import QtWidgets as QW
+
+from sigimax.widgets.wizard import Wizard, WizardPage
+
+pytestmark = pytest.mark.gui
+
+
+# ---------------------------------------------------------------------------
+# Test pages
+# ---------------------------------------------------------------------------
+
+
+class _PageA(WizardPage):
+ """First test page — always valid."""
+
+ def __init__(self):
+ super().__init__()
+ self.set_title("Page A")
+ self.set_subtitle("First page")
+ self._initialized = False
+
+ def initialize_page(self):
+ self._initialized = True
+ super().initialize_page()
+
+
+class _PageB(WizardPage):
+ """Second page — validity can be toggled."""
+
+ def __init__(self):
+ super().__init__()
+ self.set_title("Page B")
+ self.set_subtitle("Second page")
+ self.checkbox = QW.QCheckBox("Accept terms")
+ self.add_to_layout(self.checkbox)
+ self.set_valid(True)
+
+
+class _PageInvalid(WizardPage):
+ """A page that starts invalid."""
+
+ def __init__(self):
+ super().__init__()
+ self.set_title("Invalid Page")
+ self.set_valid(False)
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+def test_wizard_page_title_subtitle():
+ """WizardPage title and subtitle text."""
+ with qt_app_context():
+ page = _PageA()
+ assert page._title_label.text() == "Page A"
+ assert page._subtitle_label.text() == "First page"
+
+
+def test_wizard_page_validity():
+ """WizardPage validity flag and signal."""
+ with qt_app_context():
+ page = _PageA()
+ assert page.is_valid() is True
+ page.set_valid(False)
+ assert page.is_valid() is False
+ page.set_valid(True)
+ assert page.is_valid() is True
+
+
+def test_wizard_page_add_widget():
+ """WizardPage.add_to_layout with a QWidget."""
+ with qt_app_context():
+ page = WizardPage()
+ btn = QW.QPushButton("Test")
+ page.add_to_layout(btn)
+ assert page._user_layout.count() == 1
+
+
+def test_wizard_navigation_buttons():
+ """Wizard button states after page navigation."""
+ with qt_app_context():
+ wizard = Wizard()
+ wizard.add_page(_PageA())
+ wizard.add_page(_PageB(), last_page=True)
+
+ # On first page: Back disabled, Next enabled, Finish disabled
+ assert not wizard._back_btn.isEnabled()
+ assert wizard._next_btn.isEnabled()
+ assert not wizard._finish_btn.isEnabled()
+
+ # Move to next page
+ wizard.go_to_next_page()
+
+ # On last page: Back enabled, Next disabled, Finish enabled
+ assert wizard._back_btn.isEnabled()
+ assert not wizard._next_btn.isEnabled()
+ assert wizard._finish_btn.isEnabled()
+
+ # Go back
+ wizard.go_to_previous_page()
+ assert not wizard._back_btn.isEnabled()
+ assert wizard._next_btn.isEnabled()
+
+
+def test_wizard_single_page_finish():
+ """A single-page wizard should have Finish enabled when page is valid."""
+ with qt_app_context():
+ wizard = Wizard()
+ wizard.add_page(_PageA(), last_page=True)
+
+ # Single page, last page, valid → Finish enabled
+ assert wizard._finish_btn.isEnabled()
+ assert not wizard._next_btn.isEnabled()
+ assert not wizard._back_btn.isEnabled()
+
+
+def test_wizard_invalid_page_blocks_next():
+ """When a page is invalid, Next should be disabled."""
+ with qt_app_context():
+ wizard = Wizard()
+ wizard.add_page(_PageInvalid())
+ wizard.add_page(_PageB(), last_page=True)
+
+ # First page is invalid → Next disabled
+ assert not wizard._next_btn.isEnabled()
+ assert not wizard._finish_btn.isEnabled()
+
+
+def test_wizard_page_initialization():
+ """initialize_page is called when wizard navigates to a page."""
+ with qt_app_context():
+ page_a = _PageA()
+ page_b = _PageB()
+ wizard = Wizard()
+ wizard.add_page(page_a)
+ wizard.add_page(page_b, last_page=True)
+
+ # Page A is initialized when wizard is created (last_page=True triggers it
+ # on page 0)
+ assert page_a._initialized is True
+
+
+def test_wizard_set_wizard_reference():
+ """Each page should have a reference to its parent wizard."""
+ with qt_app_context():
+ page = _PageA()
+ wizard = Wizard()
+ wizard.add_page(page, last_page=True)
+ assert page.get_wizard() is wizard
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/sigimax/utils/__init__.py b/sigimax/utils/__init__.py
new file mode 100644
index 0000000..7312c28
--- /dev/null
+++ b/sigimax/utils/__init__.py
@@ -0,0 +1,11 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Utilities
+=========
+
+The :mod:`sigimax.utils` package provides utility functions
+for SigimaX and derived applications.
+"""
+
+__all__: list[str] = []
diff --git a/sigimax/utils/conf.py b/sigimax/utils/conf.py
new file mode 100644
index 0000000..b3d9325
--- /dev/null
+++ b/sigimax/utils/conf.py
@@ -0,0 +1,57 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX Configuration utilities
+"""
+
+from __future__ import annotations
+
+from guidata.userconfig import UserConfig
+
+
+class AppUserConfig(UserConfig):
+ """Application user configuration"""
+
+ def to_dict(self) -> dict:
+ """Return configuration as a dictionary"""
+ confdict = {}
+ for section in self.sections():
+ secdict = {}
+ for option, value in self.items(section, raw=self.raw):
+ secdict[option] = value
+ confdict[section] = secdict
+ return confdict
+
+
+CONF = AppUserConfig({})
+
+
+class Configuration:
+ """Configuration file"""
+
+ @classmethod
+ def initialize(cls, name: str, version: str, load: bool) -> None:
+ """Initialize configuration"""
+ CONF.set_application(name, version, load=load)
+
+ @classmethod
+ def reset(cls) -> None:
+ """Reset configuration"""
+ global CONF # pylint: disable=global-statement
+ CONF.cleanup() # Remove configuration file
+ CONF = AppUserConfig({})
+
+ @classmethod
+ def get_filename(cls) -> str:
+ """Return configuration file name"""
+ return CONF.filename()
+
+ @classmethod
+ def get_path(cls, basename: str) -> str:
+ """Return filename path inside configuration directory"""
+ return CONF.get_path(basename)
+
+ @classmethod
+ def to_dict(cls) -> dict:
+ """Return configuration as a dictionary"""
+ return CONF.to_dict()
diff --git a/sigimax/utils/qthelpers.py b/sigimax/utils/qthelpers.py
new file mode 100644
index 0000000..4477e3a
--- /dev/null
+++ b/sigimax/utils/qthelpers.py
@@ -0,0 +1,598 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX Qt utilities
+"""
+
+from __future__ import annotations
+
+import faulthandler
+import inspect
+import logging
+import os
+import os.path as osp
+import shutil
+import sys
+import time
+import traceback
+from collections.abc import Callable, Generator
+from contextlib import contextmanager
+from typing import Any
+
+import guidata
+from guidata.configtools import get_icon
+from guidata.qthelpers import grab_save_window as guidata_grab_save_window
+from guidata.utils.misc import to_string
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from sigimax.config import (
+ _,
+ get_conf,
+ get_old_log_fname,
+)
+from sigimax.env import execenv
+
+
+# Used internally by sigimax_app_context
+def close_widgets_and_quit(screenshot=False) -> None:
+ """Close Qt top level widgets and quit Qt event loop"""
+ for widget in QW.QApplication.instance().topLevelWidgets():
+ try:
+ wname = widget.objectName()
+ except RuntimeError:
+ # Object has been deleted
+ continue
+ if screenshot and wname and widget.isVisible(): # pragma: no cover
+ grab_save_window(widget, wname.lower())
+ assert widget.close()
+ QW.QApplication.instance().quit()
+
+
+QAPP_INSTANCE = None
+
+
+# Used internally by initialize_log_file and remove_empty_log_file
+def get_log_contents(fname: str) -> str | None:
+ """Return True if file exists and something was logged in it"""
+ if osp.exists(fname):
+ with open(fname, "rb") as fdesc:
+ return to_string(fdesc.read()).strip()
+ return None
+
+
+# Used internally by sigimax_app_context
+def initialize_log_file(fname: str) -> bool:
+ """Eventually keep the previous log file
+ Returns True if there was a previous log file"""
+ contents = get_log_contents(fname)
+ if contents:
+ try:
+ shutil.move(fname, get_old_log_fname(fname))
+ except Exception: # pylint: disable=broad-except
+ pass
+ return True
+ return False
+
+
+# Used internally by sigimax_app_context
+def remove_empty_log_file(fname: str) -> None:
+ """Eventually remove empty log files"""
+ if not get_log_contents(fname):
+ try:
+ os.remove(fname)
+ except Exception: # pylint: disable=broad-except
+ pass
+
+
+# Used in SigimaX tests and app launcher
+@contextmanager
+def sigimax_app_context(
+ exec_loop=False, enable_logs=True
+) -> Generator[QW.QApplication, None, None]:
+ """SigimaX Qt application context manager, handling Qt application creation
+ and persistance, faulthandler/traceback logging features, screenshot mode
+ and unattended mode.
+
+ Args:
+ exec_loop: whether to execute Qt event loop (default: False)
+ enable_logs: whether to enable logs (default: True)
+ """
+ global QAPP_INSTANCE # pylint: disable=global-statement
+ if QAPP_INSTANCE is None:
+ QAPP_INSTANCE = guidata.qapplication()
+
+ conf = get_conf()
+
+ # === Set application name and version ---------------------------------------------
+ QAPP_INSTANCE.setApplicationName(conf.app_name.get())
+ QAPP_INSTANCE.setApplicationVersion(conf.app_version.get())
+ QAPP_INSTANCE.setOrganizationName(conf.app_name.get() + " project")
+
+ if enable_logs:
+ # === Create a logger for standard exceptions ----------------------------------
+ tb_log_fname = conf.traceback_log_path.get()
+ conf.traceback_log_available.set(initialize_log_file(tb_log_fname))
+ logger = logging.getLogger(__name__)
+ fmt = "[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s"
+ logging.basicConfig(
+ filename=tb_log_fname,
+ filemode="w",
+ level=logging.ERROR,
+ format=fmt,
+ datefmt=conf.datetime_format.get(),
+ )
+
+ def custom_excepthook(exc_type, exc_value, exc_traceback):
+ "Custom exception hook"
+ logger.critical(
+ "Unhandled exception", exc_info=(exc_type, exc_value, exc_traceback)
+ )
+ return sys.__excepthook__(exc_type, exc_value, exc_traceback)
+
+ sys.excepthook = custom_excepthook
+
+ # === Use faulthandler for other exceptions ------------------------------------
+ fh_log_fname = conf.faulthandler_log_path.get()
+ conf.faulthandler_log_available.set(initialize_log_file(fh_log_fname))
+
+ with open(fh_log_fname, "w", encoding="utf-8") as fh_log_fn:
+ if enable_logs and conf.faulthandler_enabled.get():
+ faulthandler.enable(file=fh_log_fn)
+ exception_occured = False
+ try:
+ yield QAPP_INSTANCE
+ except Exception: # pylint: disable=broad-except
+ exception_occured = True
+ finally:
+ if (
+ execenv.unattended or execenv.screenshot
+ ) and not execenv.do_not_quit: # pragma: no cover
+ if execenv.delay > 0:
+ mode = "Screenshot" if execenv.screenshot else "Unattended"
+ message = f"{mode} mode (delay: {execenv.delay}ms)"
+ msec = execenv.delay - 200
+ for widget in QW.QApplication.instance().topLevelWidgets():
+ if isinstance(widget, QW.QMainWindow):
+ widget.statusBar().showMessage(message, msec)
+ QC.QTimer.singleShot(
+ execenv.delay,
+ lambda: close_widgets_and_quit(screenshot=execenv.screenshot),
+ )
+ if exec_loop and not exception_occured:
+ QAPP_INSTANCE.exec()
+ if exception_occured:
+ raise # pylint: disable=misplaced-bare-raise
+
+ if enable_logs and conf.faulthandler_enabled.get():
+ faulthandler.disable()
+ remove_empty_log_file(fh_log_fname)
+ if enable_logs:
+ logging.shutdown()
+ remove_empty_log_file(tb_log_fname)
+
+
+# Used internally by qt_try_loadsave_file and qt_handle_error_message
+def is_running_tests() -> bool:
+ """Check if code is running during test execution"""
+ return "pytest" in sys.modules
+
+
+# NOT used in SigimaX — kept for derived apps (e.g. plugin error handling)
+@contextmanager
+def try_or_log_error(context: str) -> Generator[None, None, None]:
+ """Try to execute a function and log an error message if it fails"""
+ try:
+ yield
+ except Exception: # pylint: disable=broad-except
+ if is_running_tests():
+ # If we are running tests, we want to raise the exception
+ raise
+ traceback.print_exc()
+ logger = logging.getLogger(__name__)
+ logger.error("Error in %s", context, exc_info=traceback.format_exc())
+ get_conf().traceback_log_available.set(True)
+ finally:
+ pass
+
+
+# NOT used in SigimaX — kept for derived apps (progress dialog utility)
+@contextmanager
+def create_progress_bar(
+ parent: QW.QWidget, label: str, max_: int, show_after: int = 1000
+) -> Generator[QW.QProgressDialog, None, None]:
+ """Create modal progress bar
+
+ Args:
+ parent: Parent widget
+ label: Progress dialog title
+ max_: Maximum progress value
+ show_after: Delay before showing the progress dialog (ms, default: 1000)
+ """
+ prog = QW.QProgressDialog(label, _("Cancel"), 0, max_, parent, QC.Qt.SplashScreen)
+ prog.setWindowModality(QC.Qt.WindowModal)
+ prog.setMinimumDuration(show_after)
+ try:
+ yield prog
+ finally:
+ prog.close()
+ prog.deleteLater()
+
+
+# NOT used in SigimaX — kept for derived apps (threaded computation worker)
+class CallbackWorker(QC.QThread):
+ """Worker for executing long operations in a separate thread.
+
+ Implements `CallbackWorkerProtocol` from `sigima.worker`, used for computations
+ that support cancellation and progress reporting.
+
+ Args:
+ callback: The function to be executed in a separate thread, that takes
+ optionnally 'worker' as argument (instance of this class), and any other
+ argument passed with **kwargs
+ kwargs: Callback keyword arguments
+ """
+
+ SIG_PROGRESS_UPDATE = QC.Signal(int)
+
+ def __init__(self, callback: Callable, **kwargs) -> None:
+ super().__init__()
+ self.callback = callback
+ if "worker" in inspect.signature(callback).parameters:
+ kwargs["worker"] = self
+ self.kwargs = kwargs
+ self.result: Any | None = None
+ self.__canceled = False
+ self.__exc = None
+
+ def run(self) -> None:
+ """Start thread"""
+ # Initialize progress bar: setting progress to 0.0 has the effect of
+ # showing the progress dialog after the `minimumDuration` time has elapsed.
+ # If we don't set the progress to 0.0, the progress dialog will be shown only
+ # after the first call to `set_progress` method even if the `minimumDuration`
+ # time has elapsed.
+ self.set_progress(0.0)
+
+ try:
+ self.result = self.callback(**self.kwargs)
+ except Exception as exc: # pylint: disable=broad-except
+ self.__exc = exc
+
+ def cancel(self) -> None:
+ """Progress bar was canceled"""
+ self.__canceled = True
+
+ def was_canceled(self) -> bool:
+ """Return whether the progress dialog was canceled by user"""
+ return self.__canceled
+
+ def set_progress(self, value: float) -> None:
+ """Set progress bar value
+
+ Args:
+ value: float between 0.0 and 1.0
+ """
+ self.SIG_PROGRESS_UPDATE.emit(int(100 * value))
+
+ def get_result(self) -> Any:
+ """Return callback result"""
+ if self.__exc is not None:
+ raise self.__exc
+ return self.result
+
+
+# NOT used in SigimaX — kept for derived apps (long callback with progress)
+def qt_long_callback(
+ parent: QW.QWidget,
+ label: str,
+ worker: CallbackWorker,
+ progress: bool,
+ show_after: int = 500,
+) -> Any:
+ """Handle long callbacks: run in a separate thread while showing a busy bar
+
+ Args:
+ parent: Parent widget
+ label: Progress dialog title
+ worker: Callback worker handling the function execution in a separate thread
+ progress: Whether the progress feature is handled or not. If True, a progress
+ bar and a 'Cancel' button are shown on the progress dialog. The progress value
+ is updated by the `worker.set_progress` method (which takes a float between
+ 0.0 and 1.0). Moreover, if `progress` is True, we wait for the callback
+ function to return (it means that the callback function must implement a
+ mechanism to return an intermediate result or `None` if the
+ `worker.was_canceled` method returns True).
+ show_after: Delay before showing the progress dialog (ms, default: 1000)
+
+ Returns:
+ Callback result
+ """
+ if progress:
+ prog = QW.QProgressDialog(
+ label, _("Cancel"), 0, 100, parent, QC.Qt.SplashScreen
+ )
+ prog.setMinimumDuration(show_after)
+ worker.SIG_PROGRESS_UPDATE.connect(prog.setValue)
+ prog.canceled.connect(worker.cancel)
+ else:
+ prog = QW.QProgressDialog(label, None, 0, 0, parent, QC.Qt.SplashScreen)
+ prog.setMinimumDuration(0)
+ prog.setCancelButton(None)
+ prog.setRange(0, 0)
+ prog.show()
+ prog.setWindowModality(QC.Qt.WindowModal)
+
+ worker.start()
+ while worker.isRunning() and not worker.was_canceled():
+ QW.QApplication.processEvents()
+ time.sleep(0.005)
+ if progress:
+ worker.SIG_PROGRESS_UPDATE.disconnect(prog.setValue)
+ worker.wait()
+ try:
+ result = worker.get_result()
+ except Exception as exc: # pylint: disable=broad-except
+ prog.close()
+ prog.deleteLater()
+ raise exc
+ prog.close()
+ prog.deleteLater()
+ return result
+
+
+# Used in SigimaX: mainwindow.py, widgets/h5browser.py
+def qt_handle_error_message(widget: QW.QWidget, message: str, context: str = None):
+ """Handles application (QWidget) error message"""
+ traceback.print_exc()
+ txt = str(message)
+ msglines = txt.splitlines()
+ firstline = _("Error:") if context is None else f"%s: {context}" % _("Context")
+ msglines.insert(0, firstline)
+ if len(msglines) > 10:
+ msglines = msglines[:10] + ["..."]
+ title = widget.window().objectName()
+ QW.QMessageBox.critical(widget, title, os.linesep.join(msglines))
+
+
+# Used in SigimaX: mainwindow.py (HDF5 load/save)
+@contextmanager
+def qt_try_loadsave_file(
+ parent: QW.QWidget, filename: str, operation: str
+) -> Generator[str, None, None]:
+ """Try and open file (operation: "load" or "save")"""
+ if operation not in ("load", "save"):
+ raise ValueError("operation argument must be 'load' or 'save'")
+ try:
+ yield filename
+ except Exception as msg: # pylint: disable=broad-except
+ if is_running_tests():
+ # If we are running tests, we want to raise the exception
+ raise
+ traceback.print_exc()
+ url = osp.dirname(filename).replace("\\", "/")
+ if operation == "load":
+ text = _("The file %s could not be read:")
+ else:
+ text = _("The file %s could not be written:")
+ in_folder = _("in this folder")
+ message = text % (
+ f"{osp.basename(filename)}"
+ f" ({in_folder})"
+ )
+ QW.QMessageBox.critical(
+ parent, get_conf().app_name.get(), f"{message}
{str(msg)}"
+ )
+ finally:
+ pass
+
+
+# Used in SigimaX: mainwindow.py (screenshot capture)
+def grab_save_window(
+ widget: QW.QWidget, name: str | None = None, add_timestamp: bool = True
+) -> None: # pragma: no cover
+ """Grab window screenshot and save it.
+
+ Delegates to guidata's ``grab_save_window``, using
+ ``execenv.screenshot_path`` as the save directory (falls back to the
+ current working directory if not set).
+
+ The screenshot path can be configured by derived apps::
+
+ # Programmatically (e.g. in tests/__init__.py or app startup)
+ execenv.screenshot_path = "/path/to/screenshots"
+
+ # Or via environment variable
+ os.environ["GUIDATA_SCREENSHOT_PATH"] = "/path/to/screenshots"
+
+ # Or via CLI argument (parsed by SGMXExecEnv)
+ # --screenshot_path /path/to/screenshots
+
+ Args:
+ widget: Widget to grab
+ name: Screenshot name (if None, uses widget.objectName())
+ add_timestamp: Whether to add a timestamp to the screenshot name
+ """
+ guidata_grab_save_window(
+ widget=widget,
+ name=name,
+ save_dir=execenv.screenshot_path or None,
+ add_timestamp=add_timestamp,
+ )
+
+
+# Used in SigimaX: mainwindow.py (file dialogs)
+@contextmanager
+def save_restore_stds() -> Generator[None, None, None]:
+ """Save/restore standard I/O before/after doing some things
+ (e.g. calling Qt open/save dialogs)"""
+ saved_in, saved_out, saved_err = sys.stdin, sys.stdout, sys.stderr
+ sys.stdout = None
+ try:
+ yield
+ finally:
+ sys.stdin, sys.stdout, sys.stderr = saved_in, saved_out, saved_err
+
+
+# Used in SigimaX: widgets/h5browser.py, widgets/signalcursor.py
+@contextmanager
+def block_signals(
+ widget: QW.QWidget, enable: bool = True, children: bool = False
+) -> Generator[None, None, None]:
+ """Eventually block/unblock widget Qt signals before/after doing some things
+
+ Args:
+ widget: Widget to block/unblock signals
+ enable: Whether to block/unblock signals (default: True). This is useful
+ to avoid blocking signals when not needed without having to handle it by
+ adding an `if` statement which would require to duplicate the code that is
+ inside the `with` statement in the `else` branch.
+ children: Whether to block/unblock signals for child widgets (default: False).
+
+ Returns:
+ Context manager
+ """
+ if enable:
+ widget.blockSignals(True)
+ if children:
+ for child in widget.findChildren(QW.QWidget):
+ child.blockSignals(True)
+ try:
+ yield
+ finally:
+ if enable:
+ widget.blockSignals(False)
+ if children:
+ for child in widget.findChildren(QW.QWidget):
+ child.blockSignals(False)
+
+
+# Used in SigimaX: mainwindow.py (window management)
+def bring_to_front(window: QW.QWidget) -> None:
+ """Bring window to front
+
+ Args:
+ window: Window to bring to front
+ """
+ # Show window on top of others
+ eflags = window.windowFlags()
+ window.setWindowFlags(eflags | QC.Qt.WindowStaysOnTopHint)
+ window.show()
+ window.setWindowFlags(eflags)
+ window.show()
+ # If window is minimized, restore it
+ if window.isMinimized():
+ window.showNormal()
+
+
+# Used in SigimaX: mainwindow.py (file/view menus)
+def configure_menu_about_to_show(menu: QW.QMenu, slot: Callable) -> None:
+ """Configure menu about to show.
+ This method is only used to connect the "aboutToShow" signal of menus,
+ and more importantly to fix Issue #15 (Part 2) which is the fact that
+ dynamic menus are not supported on MacOS unless an action is added to
+ the menu before it is displayed.
+
+ Args:
+ menu: menu
+ slot: slot
+ """
+ # On MacOS, add an empty action to the menu before connecting the
+ # "aboutToShow" signal to the slot. This is required to fix Issue #15 (Part 2)
+ if sys.platform == "darwin":
+ menu.addAction(QW.QAction(menu))
+ menu.aboutToShow.connect(slot)
+
+
+# Used in SigimaX: widgets/signalpeak, signaldeltax, signalcursor,
+# signalbaseline, imagebackground
+def resize_widget_to_parent(
+ widget: QW.QWidget,
+ parent: QW.QWidget | None = None,
+ ratio: float = 0.95,
+ aspect_ratio: float = 1.0,
+ min_size: int = 500,
+) -> None:
+ """Resize widget based on parent widget's dimensions
+
+ Args:
+ widget: Widget to resize
+ parent: Parent widget (if None, uses widget.parentWidget())
+ ratio: Ratio of parent size to use (0.0 to 1.0, default: 0.95 for 95%).
+ This represents the percentage of the maximum dimension with respect
+ to the widget.
+ aspect_ratio: Width/height ratio (1.0 for square, >1.0 for landscape,
+ <1.0 for portrait, default: 1.0)
+ min_size: Minimum size in pixels (default: 500)
+ """
+ if parent is None:
+ parent = widget.parentWidget()
+
+ if parent is not None:
+ parent_size = parent.size()
+ parent_width = parent_size.width()
+ parent_height = parent_size.height()
+
+ # Calculate maximum available dimensions
+ max_width = parent_width * ratio
+ max_height = parent_height * ratio
+
+ # Determine which dimension is limiting based on aspect ratio
+ # For aspect_ratio = w/h, we have: w = aspect_ratio * h
+ # Check which constraint is more restrictive
+ width_from_height = max_height * aspect_ratio
+ height_from_width = max_width / aspect_ratio
+
+ if width_from_height <= max_width:
+ # Height is the limiting factor
+ height = int(max_height)
+ width = int(width_from_height)
+ else:
+ # Width is the limiting factor
+ width = int(max_width)
+ height = int(height_from_width)
+
+ # Ensure minimum size while preserving aspect ratio
+ if width < min_size or height < min_size:
+ if aspect_ratio >= 1.0:
+ # Landscape or square: scale up from minimum width
+ width = max(width, min_size)
+ height = max(height, int(min_size / aspect_ratio))
+ else:
+ # Portrait: scale up from minimum height
+ height = max(height, min_size)
+ width = max(width, int(min_size * aspect_ratio))
+
+ # Final check: ensure we don't exceed parent dimensions
+ width = min(width, parent_width)
+ height = min(height, parent_height)
+
+ widget.resize(width, height)
+ else:
+ # Fallback: use square with min_size if no parent
+ widget.resize(min_size, min_size)
+
+
+# Used in SigimaX: mainwindow.py (tab widget corner menu)
+def add_corner_menu(
+ tabwidget: QW.QTabWidget, corner: QC.Qt.Corner | None = None
+) -> QW.QMenu:
+ """Add menu as corner widget to tab widget
+
+ Args:
+ tabwidget: Tab widget
+ corner: Corner
+
+ Returns:
+ Menu
+ """
+ if corner is None:
+ corner = QC.Qt.TopRightCorner
+ menu = QW.QMenu(tabwidget)
+ btn = QW.QToolButton(tabwidget)
+ btn.setMenu(menu)
+ btn.setPopupMode(QW.QToolButton.InstantPopup)
+ btn.setIcon(get_icon("menu.svg"))
+ btn.setToolTip(_("Open tab menu"))
+ tabwidget.setCornerWidget(btn, corner)
+ return menu
diff --git a/sigimax/widgets/__init__.py b/sigimax/widgets/__init__.py
new file mode 100644
index 0000000..ed94117
--- /dev/null
+++ b/sigimax/widgets/__init__.py
@@ -0,0 +1,72 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX widgets
+===============
+
+Reusable Qt widgets for SigimaX-derived applications.
+
+Convenience imports
+-------------------
+
+The most commonly used widgets are re-exported here for easy access::
+
+ from sigimax.widgets import H5Browser, Wizard, LogViewerWindow
+
+Specialized scientific dialogs (fit, peak detection, baseline, etc.) remain
+accessible via their submodule::
+
+ from sigimax.widgets.fitdialog import gaussian_fit
+ from sigimax.widgets.signalpeak import SignalPeakDetectionDialog
+
+Submodules
+----------
+
+.. autosummary::
+
+ plotdock
+ filedialog
+ fileviewer
+ fitdialog
+ h5browser
+ imagebackground
+ logviewer
+ signalbaseline
+ signalcursor
+ signaldeltax
+ signalpeak
+ splashscreen
+ status
+ warningerror
+ wizard
+"""
+
+from sigimax.widgets.h5browser import H5Browser, H5BrowserDialog
+from sigimax.widgets.logviewer import LogViewerWindow
+from sigimax.widgets.plotdock import (
+ CurveStatsToolFunctions,
+ DockablePlotWidget,
+ SigimaXPlotWidget,
+)
+from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig
+from sigimax.widgets.status import BaseStatus, ConsoleStatus, MemoryStatus
+from sigimax.widgets.warningerror import WarningErrorMessageBox, show_warning_error
+from sigimax.widgets.wizard import Wizard, WizardPage
+
+__all__ = [
+ "BaseStatus",
+ "ConsoleStatus",
+ "CurveStatsToolFunctions",
+ "DockablePlotWidget",
+ "H5Browser",
+ "H5BrowserDialog",
+ "LogViewerWindow",
+ "MemoryStatus",
+ "SigimaXPlotWidget",
+ "SigimaXSplashScreen",
+ "SplashScreenConfig",
+ "WarningErrorMessageBox",
+ "Wizard",
+ "WizardPage",
+ "show_warning_error",
+]
diff --git a/sigimax/widgets/filedialog.py b/sigimax/widgets/filedialog.py
new file mode 100644
index 0000000..9789d53
--- /dev/null
+++ b/sigimax/widgets/filedialog.py
@@ -0,0 +1,96 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Module providing a file dialog widget based on Qt's QFileDialog.getOpenFileNames
+but supporting multiple file preselection (Qt original dialog only supports single file
+selection).
+
+.. autofunction:: get_open_file_names
+"""
+
+from __future__ import annotations
+
+import os
+import os.path as osp
+
+from guidata.qthelpers import qt_app_context
+from qtpy.QtCore import QItemSelectionModel
+from qtpy.QtWidgets import QAbstractItemView, QFileDialog, QListView, QWidget
+
+__all__ = [
+ "get_open_file_names",
+]
+
+
+def get_open_file_names(
+ parent: QWidget | None = None,
+ caption: str = "",
+ basedir: str | list[str] = "",
+ filters: str = "",
+ selectedfilter: str = "",
+ options: QFileDialog.Options = None,
+) -> tuple[list[str], str]:
+ """Wrapper around QtGui.QFileDialog.getOpenFileNames static method
+ Returns a tuple (filenames, selectedfilter) -- when dialog box is canceled,
+ returns a tuple (empty list, empty string)
+
+ Args:
+ parent: Parent widget for the dialog.
+ caption: Dialog title.
+ basedir: Initial directory to open the dialog in, or preselected files
+ (single string or list of strings).
+ filters: File filters for the dialog.
+ selectedfilter: Default filter to be selected.
+ options: Additional options for the dialog.
+
+ Returns:
+ A tuple containing a list of selected filenames and the selected filter.
+ """
+ if isinstance(basedir, str):
+ if osp.isfile(basedir):
+ sel_files = [basedir]
+ basedir = osp.dirname(basedir)
+ else:
+ sel_files = []
+ else:
+ assert isinstance(basedir, list)
+ sel_files = basedir
+ basedir = osp.dirname(sel_files[0]) if sel_files else ""
+ dlg = QFileDialog(
+ parent, caption, basedir, filters, options=QFileDialog.DontUseNativeDialog
+ )
+ if options is not None:
+ dlg.setOptions(options | QFileDialog.DontUseNativeDialog)
+ file_view = dlg.findChild(QListView, "listView")
+ sel_model = file_view.selectionModel()
+ for fname in sel_files:
+ idx = sel_model.model().index(fname)
+ sel_model.select(idx, QItemSelectionModel.Select | QItemSelectionModel.Rows)
+ file_view.setSelectionMode(QAbstractItemView.ExtendedSelection)
+ file_view.setSelectionBehavior(QAbstractItemView.SelectRows)
+ if dlg.exec():
+ filenames = dlg.selectedFiles()
+ selectedfilter = dlg.selectedNameFilter()
+ else:
+ filenames = []
+ selectedfilter = ""
+ return filenames, selectedfilter
+
+
+def test_get_open_file_names():
+ """Test get_open_file_names function"""
+ widgets_path = osp.dirname(__file__)
+ sel_files = [
+ osp.join(widgets_path, fname) for fname in os.listdir(widgets_path)[:2]
+ ]
+ with qt_app_context():
+ filenames, selectedfilter = get_open_file_names(
+ filters="Python files (*.py);;All files (*)",
+ caption="Select Python files",
+ basedir=sel_files,
+ )
+ print(filenames, selectedfilter)
+
+
+if __name__ == "__main__":
+ test_get_open_file_names()
diff --git a/sigimax/widgets/fileviewer.py b/sigimax/widgets/fileviewer.py
new file mode 100644
index 0000000..114de18
--- /dev/null
+++ b/sigimax/widgets/fileviewer.py
@@ -0,0 +1,93 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Module providing a file viewer widget
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from guidata.configtools import get_icon
+from guidata.widgets.codeeditor import CodeEditor
+from qtpy import QtWidgets as QW
+
+from sigimax.config import _, get_conf
+
+__all__ = [
+ "FileViewerWidget",
+ "get_title_contents",
+ "read_text_file",
+]
+
+
+def read_text_file(path: str) -> str:
+ """Read text file using multiple encodings
+
+ Args:
+ path (str): path to file
+
+ Raises:
+ UnicodeDecodeError: if unable to read file using any of the encodings
+
+ Returns:
+ str: file contents
+ """
+ encodings = ["utf-8", "latin1", "cp1252", "utf-16", "utf-32", "ascii"]
+ for encoding in encodings:
+ try:
+ with open(path, "r", encoding=encoding) as fdesc:
+ return fdesc.read()
+ except UnicodeDecodeError:
+ pass
+ raise UnicodeDecodeError(
+ f"Unable to read file using the following encodings: {encodings}"
+ )
+
+
+def get_title_contents(path: str) -> tuple[str, str]:
+ """Get title and contents for log filename
+
+ Args:
+ path (str): path to file
+
+ Returns:
+ tuple[str, str]: title and contents
+ """
+ contents = read_text_file(path)
+ pathobj = Path(path)
+ uri_path = pathobj.absolute().as_uri()
+ prefix = _("Contents of file")
+ text = f'{prefix} {path}:'
+ return text, contents
+
+
+class FileViewerWidget(QW.QWidget):
+ """File viewer widget
+
+ Args:
+ parent (QW.QWidget | None): parent widget. Defaults to None.
+ """
+
+ def __init__(self, language: str | None = None, parent: QW.QWidget = None) -> None:
+ super().__init__(parent)
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ self.editor = CodeEditor(language=language)
+ self.editor.setReadOnly(True)
+ layout = QW.QVBoxLayout()
+ self.label = QW.QLabel("")
+ layout.addWidget(self.label)
+ layout.addWidget(self.editor)
+ self.setLayout(layout)
+
+ def set_data(self, text: str, contents: str) -> None:
+ """Set log data
+
+ Args:
+ text (str): text to display
+ contents (str): contents to display
+ """
+ self.label.setText(text)
+ self.label.setOpenExternalLinks(True)
+ self.editor.setPlainText(contents)
diff --git a/sigimax/widgets/fitdialog.py b/sigimax/widgets/fitdialog.py
new file mode 100644
index 0000000..6799d9b
--- /dev/null
+++ b/sigimax/widgets/fitdialog.py
@@ -0,0 +1,940 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Curve fitting dialog widgets
+
+.. autofunction:: guifit
+.. autofunction:: linear_fit
+.. autofunction:: polynomial_fit
+.. autofunction:: gaussian_fit
+.. autofunction:: lorentzian_fit
+.. autofunction:: voigt_fit
+.. autofunction:: multigaussian_fit
+.. autofunction:: multilorentzian_fit
+.. autofunction:: exponential_fit
+.. autofunction:: sinusoidal_fit
+.. autofunction:: cdf_fit
+.. autofunction:: planckian_fit
+.. autofunction:: twohalfgaussian_fit
+.. autofunction:: piecewiseexponential_fit
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+import numpy as np
+from guidata.configtools import get_icon
+from guidata.qthelpers import exec_dialog
+from plotpy.plot import PlotOptions
+from plotpy.widgets.fit import FitDialog, FitParam
+from scipy.special import erf # pylint: disable=no-name-in-module
+from sigima.tools.checks import check_1d_arrays
+from sigima.tools.signal import fitting, fourier, pulse
+
+from sigimax.config import _, get_conf
+
+__all__ = [
+ "cdf_fit",
+ "exponential_fit",
+ "gaussian_fit",
+ "guifit",
+ "linear_fit",
+ "lorentzian_fit",
+ "multigaussian_fit",
+ "multilorentzian_fit",
+ "piecewiseexponential_fit",
+ "planckian_fit",
+ "polynomial_fit",
+ "sinusoidal_fit",
+ "twohalfgaussian_fit",
+ "voigt_fit",
+]
+
+DEFAULT_FORMAT = "%g"
+
+
+def create_interactive_fit_params(fit_type, values, y, y_fitted):
+ """Create canonical metadata for a fit committed from a dialog."""
+ residual_rms = np.sqrt(np.mean((y - y_fitted) ** 2))
+ return fitting.create_fit_params(
+ fit_type, values, residual_rms=residual_rms, interactive=True
+ )
+
+
+def guifit(
+ x,
+ y,
+ fitfunc,
+ fitparams,
+ fitargs=None,
+ fitkwargs=None,
+ wintitle=None,
+ title=None,
+ xlabel=None,
+ ylabel=None,
+ param_cols=1,
+ auto_fit=True,
+ winsize=None,
+ winpos=None,
+ parent=None,
+ name=None,
+): # pylint: disable=too-many-positional-arguments
+ """GUI-based curve fitting tool"""
+ win = FitDialog(
+ edit=True,
+ title=wintitle,
+ icon=None,
+ toolbar=True,
+ options=PlotOptions(
+ title=title,
+ xlabel=xlabel,
+ ylabel=ylabel,
+ curve_antialiasing=True,
+ show_axes_tab=False,
+ autoscale_margin_percent=get_conf().sig_autoscale_margin_percent.get(),
+ ),
+ parent=parent,
+ param_cols=param_cols,
+ auto_fit=auto_fit,
+ )
+ win.setObjectName(name)
+ win.set_data(x, y, fitfunc, fitparams, fitargs, fitkwargs)
+ try:
+ win.autofit() # TODO: [P3] make this optional
+ except ValueError:
+ pass
+ if parent is None:
+ win.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ if winsize is not None:
+ win.resize(*winsize)
+ if winpos is not None:
+ win.move(*winpos)
+ win.get_plot().do_autoscale()
+ if exec_dialog(win):
+ return win.get_values()
+ return None
+
+
+# --- Polynomial fitting curve -------------------------------------------------
+def polynomial_fit(x, y, degree, parent=None, name=None, fit_type="polynomial"):
+ """Compute polynomial fit
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ computer = fitting.PolynomialFitComputer(x, y, degree)
+ ivals = np.polyfit(x, y, degree)
+
+ params = []
+ for index in range(degree + 1):
+ val = ivals[index]
+ vmax = max(1.0, np.abs(val))
+ param = FitParam(
+ f"c{(len(ivals) - index - 1):d}",
+ val,
+ -2 * vmax,
+ 2 * vmax,
+ format=DEFAULT_FORMAT,
+ )
+ params.append(param)
+
+ def fitfunc(x, params):
+ return np.polyval(params, x)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Polymomial fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ # Both `np.polyfit` and `PolynomialFitComputer` order coefficients from
+ # the highest degree to the lowest, so a plain zip is correct here.
+ fit_values = dict(zip(computer.get_params_names(), values))
+ fit_params = create_interactive_fit_params(fit_type, fit_values, y, y_fitted)
+ return y_fitted, params, fit_params
+
+
+def linear_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute linear fit using polynomialfit.
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary
+ """
+ # A first-degree polynomial and a linear fit share the same `(a, b)` parameter
+ # names, so only the stored fit type has to be overridden.
+ return polynomial_fit(x, y, 1, parent=parent, name=name, fit_type="linear")
+
+
+# --- Gaussian fitting curve ---------------------------------------------------
+def gaussian_fit(x, y, parent=None, name=None):
+ """Compute Gaussian fit
+
+ Returns (yfit, params), where yfit is the fitted curve and params are
+ the fitting parameters"""
+ # Get initial parameter estimates from Sigima GaussianFitComputer
+ computer = fitting.GaussianFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ amplitude_guess = initial_params["amplitude"]
+ sigma_guess = initial_params["sigma"]
+ mu_guess = initial_params["x0"]
+ b_guess = initial_params["y0"]
+
+ dy = np.max(y) - np.min(y)
+ max_amplitude = max(2.0 * dy, 2.0 * abs(amplitude_guess))
+ amplitude = FitParam(
+ _("Amplitude"),
+ amplitude_guess,
+ -max_amplitude,
+ max_amplitude,
+ format=DEFAULT_FORMAT,
+ )
+ b = FitParam(
+ _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT
+ )
+ sigma = FitParam(
+ _("Std-dev") + " (σ)",
+ sigma_guess,
+ sigma_guess * 0.1,
+ sigma_guess * 10,
+ format=DEFAULT_FORMAT,
+ )
+ mu = FitParam(
+ _("Mean") + " (μ)", mu_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT
+ )
+
+ params = [amplitude, sigma, mu, b]
+
+ def fitfunc(x, params):
+ return pulse.GaussianModel.evaluate(x, *params)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Gaussian fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "gaussian", dict(zip(computer.get_params_names(), values)), y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Lorentzian fitting curve -------------------------------------------------
+def lorentzian_fit(x, y, parent=None, name=None):
+ """Compute Lorentzian fit
+
+ Returns (yfit, params), where yfit is the fitted curve and params are
+ the fitting parameters"""
+ # Get initial parameter estimates from Sigima LorentzianFitComputer
+ computer = fitting.LorentzianFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ amplitude_guess = initial_params["amplitude"]
+ sigma_guess = initial_params["sigma"]
+ mu_guess = initial_params["x0"]
+ b_guess = initial_params["y0"]
+
+ # Create parameter bounds
+ dy = np.max(y) - np.min(y)
+
+ max_amplitude = max(2.0 * dy, 2.0 * abs(amplitude_guess))
+ amplitude = FitParam(
+ _("Amplitude"),
+ amplitude_guess,
+ -max_amplitude,
+ max_amplitude,
+ format=DEFAULT_FORMAT,
+ )
+ b = FitParam(
+ _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT
+ )
+ sigma = FitParam(
+ _("Std-dev") + " (σ)",
+ sigma_guess,
+ sigma_guess * 0.1,
+ sigma_guess * 10,
+ format=DEFAULT_FORMAT,
+ )
+ mu = FitParam(
+ _("Mean") + " (μ)", mu_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT
+ )
+
+ params = [amplitude, sigma, mu, b]
+
+ def fitfunc(x, params):
+ return pulse.LorentzianModel.evaluate(x, *params)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Lorentzian fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "lorentzian", dict(zip(computer.get_params_names(), values)), y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Voigt fitting curve ------------------------------------------------------
+def voigt_fit(x, y, parent=None, name=None):
+ """Compute Voigt fit
+
+ Returns (yfit, params), where yfit is the fitted curve and params are
+ the fitting parameters"""
+ # Get initial parameter estimates from Sigima VoigtFitComputer
+ computer = fitting.VoigtFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ amplitude_guess = initial_params["amplitude"]
+ sigma_guess = initial_params["sigma"]
+ mu_guess = initial_params["x0"]
+ b_guess = initial_params["y0"]
+
+ # Create parameter bounds
+ dy = np.max(y) - np.min(y)
+
+ max_amplitude = max(2.0 * dy, 2.0 * abs(amplitude_guess))
+ amplitude = FitParam(
+ _("Amplitude"),
+ amplitude_guess,
+ -max_amplitude,
+ max_amplitude,
+ format=DEFAULT_FORMAT,
+ )
+ b = FitParam(
+ _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT
+ )
+ sigma = FitParam(
+ _("Std-dev") + " (σ)",
+ sigma_guess,
+ sigma_guess * 0.1,
+ sigma_guess * 10,
+ format=DEFAULT_FORMAT,
+ )
+ mu = FitParam(
+ _("Mean") + " (μ)", mu_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT
+ )
+
+ params = [amplitude, sigma, mu, b]
+
+ def fitfunc(x, params):
+ return pulse.VoigtModel.evaluate(x, *params)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Voigt fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "voigt", dict(zip(computer.get_params_names(), values)), y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Multi-Gaussian fitting curve ---------------------------------------------
+def multigaussian(x, *values, **kwargs):
+ """Return a 1-dimensional multi-Gaussian function."""
+ amplitudes = values[0::2]
+ a_sigma = values[1::2]
+ y0 = values[-1]
+ a_x0 = kwargs["a_x0"]
+ y = np.zeros_like(x) + y0
+ for amplitude, sigma, x0 in zip(amplitudes, a_sigma, a_x0):
+ y += pulse.GaussianModel.evaluate(x, amplitude, sigma, x0, 0.0)
+ return y
+
+
+def multigaussian_fit(x, y, peak_indices, parent=None, name=None):
+ """Compute Multi-Gaussian fit
+
+ Returns (yfit, params), where yfit is the fitted curve and params are
+ the fitting parameters"""
+ # Get initial parameter estimates from Sigima MultiGaussianFitComputer
+ computer = fitting.MultiGaussianFitComputer(x, y, peak_indices)
+ initial_params = computer.compute_initial_params()
+ # Use Sigima parameters to populate SigimaX params
+ params = []
+ for index, i0 in enumerate(peak_indices):
+ stri = f"{index + 1:02d}"
+ amplitude_key = f"amplitude_{index + 1}"
+ sigma_key = f"sigma_{index + 1}"
+ amplitude_value = initial_params.get(amplitude_key, y[i0] - np.min(y))
+ sigma_val = (
+ initial_params[sigma_key]
+ if sigma_key in initial_params
+ else (x.max() - x.min()) / 100
+ )
+
+ # Calculate bounds based on local data
+ istart = 0
+ iend = len(x) - 1
+ if index > 0:
+ istart = (peak_indices[index - 1] + i0) // 2
+ if index < len(peak_indices) - 1:
+ iend = (peak_indices[index + 1] + i0) // 2
+ dx = 0.5 * (x[iend] - x[istart])
+ dy = np.max(y[istart:iend]) - np.min(y[istart:iend])
+ amplitude_range = max(dy * 2, abs(amplitude_value) * 2)
+
+ params += [
+ FitParam(
+ ("A") + stri,
+ amplitude_value,
+ -amplitude_range,
+ amplitude_range,
+ format=DEFAULT_FORMAT,
+ ),
+ FitParam("σ" + stri, sigma_val, dx / 100, dx, format=DEFAULT_FORMAT),
+ ]
+
+ y0_val = initial_params.get("y0", np.min(y))
+ params.append(
+ FitParam(
+ _("Y0"),
+ y0_val,
+ np.min(y) - 0.1 * (np.max(y) - np.min(y)),
+ np.max(y),
+ format=DEFAULT_FORMAT,
+ )
+ )
+
+ kwargs = {"a_x0": x[peak_indices]}
+
+ def fitfunc(xi, params):
+ return multigaussian(xi, *params, **kwargs)
+
+ param_cols = 1
+ if len(params) > 8:
+ param_cols = 4
+ values = guifit(
+ x,
+ y,
+ fitfunc,
+ params,
+ param_cols=param_cols,
+ winsize=(900, 600),
+ parent=parent,
+ name=name,
+ wintitle=_("Multi-Gaussian fit"),
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_values = {"y0": values[-1]}
+ for index, (amplitude, sigma, x0) in enumerate(
+ zip(values[0::2], values[1::2], kwargs["a_x0"]), start=1
+ ):
+ fit_values[f"amplitude_{index}"] = amplitude
+ fit_values[f"sigma_{index}"] = abs(sigma)
+ fit_values[f"x0_{index}"] = x0
+ fit_params = create_interactive_fit_params(
+ "multigaussian", fit_values, y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Multi-Lorentzian fitting curve -------------------------------------------
+def multilorentzian(x, *values, **kwargs):
+ """Return a 1-dimensional multi-Lorentzian function."""
+ amplitudes = values[0::2]
+ a_sigma = values[1::2]
+ y0 = values[-1]
+ a_x0 = kwargs["a_x0"]
+ y = np.zeros_like(x) + y0
+ for amplitude, sigma, x0 in zip(amplitudes, a_sigma, a_x0):
+ y += pulse.LorentzianModel.evaluate(x, amplitude, sigma, x0, 0.0)
+ return y
+
+
+def multilorentzian_fit(
+ x: np.ndarray, y: np.ndarray, peak_indices, parent=None, name=None
+):
+ """Compute Multi-Lorentzian fit
+
+ Returns (yfit, params), where yfit is the fitted curve and params are
+ the fitting parameters"""
+ # Get initial parameter estimates from Sigima MultiLorentzianFitComputer
+ computer = fitting.MultiLorentzianFitComputer(x, y, peak_indices)
+ initial_params = computer.compute_initial_params()
+ # Use Sigima parameters to populate SigimaX params
+ params = []
+ dy = np.max(y) - np.min(y)
+ for index, i0 in enumerate(peak_indices):
+ stri = f"{index + 1:02d}"
+ amplitude_key = f"amplitude_{index + 1}"
+ sigma_key = f"sigma_{index + 1}"
+ amplitude_value = initial_params.get(amplitude_key, y[i0] - np.min(y))
+ sigma_val = (
+ initial_params[sigma_key]
+ if sigma_key in initial_params
+ else (x.max() - x.min()) / 100
+ )
+
+ params += [
+ FitParam(
+ ("A") + stri,
+ amplitude_value,
+ -max(abs(amplitude_value) * 2, dy * 2),
+ max(abs(amplitude_value) * 2, dy * 2),
+ format=DEFAULT_FORMAT,
+ ),
+ FitParam(
+ "σ" + stri,
+ sigma_val,
+ sigma_val * 0.2,
+ sigma_val * 10,
+ format=DEFAULT_FORMAT,
+ ),
+ ]
+
+ y0_val = initial_params.get("y0", np.min(y))
+ params.append(
+ FitParam(
+ _("Y0"),
+ y0_val,
+ np.min(y) - 0.1 * (np.max(y) - np.min(y)),
+ np.max(y),
+ format=DEFAULT_FORMAT,
+ )
+ )
+
+ kwargs = {"a_x0": x[peak_indices]}
+
+ def fitfunc(xi, params):
+ return multilorentzian(xi, *params, **kwargs)
+
+ param_cols = 1
+ if len(params) > 8:
+ param_cols = 4
+ values = guifit(
+ x,
+ y,
+ fitfunc,
+ params,
+ param_cols=param_cols,
+ winsize=(900, 600),
+ parent=parent,
+ name=name,
+ wintitle=_("Multi-Lorentzian fit"),
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_values = {"y0": values[-1]}
+ for index, (amplitude, sigma, x0) in enumerate(
+ zip(values[0::2], values[1::2], kwargs["a_x0"]), start=1
+ ):
+ fit_values[f"amplitude_{index}"] = amplitude
+ fit_values[f"sigma_{index}"] = abs(sigma)
+ fit_values[f"x0_{index}"] = x0
+ fit_params = create_interactive_fit_params(
+ "multilorentzian", fit_values, y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Exponential fitting curve ------------------------------------------------
+
+
+def exponential_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute exponential fit
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ # Get initial parameter estimates from Sigima ExponentialFitComputer
+ computer = fitting.ExponentialFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ oa = initial_params["a"]
+ ob = initial_params["b"]
+ oc = initial_params["y0"]
+
+ # Create parameter bounds
+ moa, mob, moc = np.maximum(1, [abs(oa), abs(ob), abs(oc)])
+ a_p = FitParam(
+ _("A coefficient"), oa, -2 * moa, 2 * moa, logscale=True, format=DEFAULT_FORMAT
+ )
+ # B must be free to change sign: a positive-only range makes every decaying
+ # exponential unreachable. Sigima uses (-10, 10) for the same parameter.
+ mob = max(10.0, 2 * mob)
+ b_p = FitParam(_("B coefficient"), ob, -mob, mob, format=DEFAULT_FORMAT)
+ c_p = FitParam(_("y0 constant"), oc, -2 * moc, 2 * moc, format=DEFAULT_FORMAT)
+
+ params = [a_p, b_p, c_p]
+
+ def modelfunc(x, a, b, c):
+ return a * np.exp(b * x) + c
+
+ def fitfunc(x, params):
+ return modelfunc(x, *params)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Exponential fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "exponential", dict(zip(computer.get_params_names(), values)), y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Sinusoidal fitting curve ------------------------------------------------
+
+
+@check_1d_arrays(x_evenly_spaced=True)
+def dominant_frequency(x: np.ndarray, y: np.ndarray) -> np.floating:
+ """Find the dominant frequency.
+
+ Args:
+ x: 1-D x values.
+ y: 1-D y values.
+
+ Returns:
+ Dominant frequency.
+ """
+ f, spectrum = fourier.magnitude_spectrum(x, y)
+ return np.abs(f[np.argmax(spectrum)])
+
+
+def sinusoidal_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute sinusoidal fit
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ # Get initial parameter estimates from Sigima SinusoidalFitComputer
+ computer = fitting.SinusoidalFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ guess_a = initial_params["amplitude"]
+ guess_f = initial_params["frequency"]
+ guess_ph = np.rad2deg(initial_params["phase"]) # Convert to degrees
+ guess_c = initial_params["offset"]
+
+ # Create parameter bounds
+ abs_values = [abs(guess_a), abs(guess_f), abs(guess_ph), abs(guess_c)]
+ moa, mof, _mop, moc = np.maximum(1, abs_values)
+ a_p = FitParam(_("Amplitude"), guess_a, -2 * moa, 2 * moa, format=DEFAULT_FORMAT)
+ f_p = FitParam(_("Frequency"), guess_f, 0, 2 * mof, format=DEFAULT_FORMAT)
+ p_p = FitParam(_("Phase"), guess_ph, -360, 360, format=DEFAULT_FORMAT)
+ c_p = FitParam(
+ _("Continuous component"), guess_c, -2 * moc, 2 * moc, format=DEFAULT_FORMAT
+ )
+
+ params = [a_p, f_p, p_p, c_p]
+
+ def modelfunc(x, a, f, p, c):
+ return a * np.sin(2 * np.pi * f * x + np.deg2rad(p)) + c
+
+ def fitfunc(x, params):
+ return modelfunc(x, *params)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Sinusoidal fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ # The phase is edited in degrees but stored in radians, as expected by
+ # Sigima's sinusoidal model.
+ amplitude, frequency, phase, offset = values
+ fit_values = dict(
+ zip(
+ computer.get_params_names(),
+ (amplitude, frequency, np.deg2rad(phase), offset),
+ )
+ )
+ fit_params = create_interactive_fit_params(
+ "sinusoidal", fit_values, y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Cumulative distribution function fitting curve -----------------------------------
+
+
+def cdf_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute Cumulative Distribution Function (CDF) fit
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ # Get initial parameter estimates from Sigima CDFFitComputer
+ computer = fitting.CDFFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ a_guess = initial_params["amplitude"]
+ mu_guess = initial_params["mu"]
+ sigma_guess = initial_params["sigma"]
+ b_guess = initial_params["baseline"]
+
+ # Create parameter bounds
+ dy = np.max(y) - np.min(y)
+ x_min, x_max = float(np.min(x)), float(np.max(x))
+ dx = x_max - x_min
+ iamp = max(1.0, abs(a_guess))
+ # Amplitude must be free to change sign, otherwise a descending transition
+ # cannot be fitted at all.
+ a = FitParam(
+ _("Amplitude"), a_guess, -iamp * 2.0, iamp * 2.0, format=DEFAULT_FORMAT
+ )
+ b = FitParam(
+ _("Base line"), b_guess, np.min(y) - 0.1 * dy, np.max(y), format=DEFAULT_FORMAT
+ )
+ # Bound sigma and mu to the abscissa range rather than to the magnitude of
+ # the initial guess, which excluded negative and near-zero means.
+ sigma = FitParam(
+ _("Std-dev") + " (σ)",
+ sigma_guess,
+ dx * 0.001,
+ dx,
+ format=DEFAULT_FORMAT,
+ )
+ mu = FitParam(_("Mean") + " (μ)", mu_guess, x_min, x_max, format=DEFAULT_FORMAT)
+
+ params = [a, mu, sigma, b]
+
+ def modelfunc(x, a, mu, sigma, b):
+ return a * erf((x - mu) / (sigma * np.sqrt(2))) + b
+
+ def fitfunc(x, params):
+ return modelfunc(x, *params)
+
+ values = guifit(
+ x,
+ y,
+ fitfunc,
+ params,
+ parent=parent,
+ wintitle=_("CDF fit"),
+ name=name,
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "cdf", dict(zip(computer.get_params_names(), values)), y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Planckian fitting curve --------------------------------------------------
+def planckian_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute Planckian (blackbody radiation) fit
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ # Get initial parameter estimates from Sigima PlanckianFitComputer
+ computer = fitting.PlanckianFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ amp_guess = initial_params["amp"]
+ x0_guess = initial_params["x0"]
+ sigma_guess = initial_params["sigma"]
+ y0_guess = initial_params["y0"]
+
+ # Create parameter bounds
+ dy = np.max(y) - np.min(y)
+
+ # Parameter bounds with appropriate ranges for Planckian fitting
+ amp = FitParam(
+ _("Amplitude"),
+ amp_guess,
+ amp_guess * 0.01,
+ amp_guess * 100,
+ format=DEFAULT_FORMAT,
+ )
+ x0 = FitParam(
+ _("Scale factor"), x0_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT
+ )
+ sigma = FitParam(_("Width factor"), sigma_guess, 0.1, 5.0, format=DEFAULT_FORMAT)
+ y0 = FitParam(
+ _("Base line"),
+ y0_guess,
+ y0_guess - 0.2 * dy,
+ y0_guess + 0.2 * dy,
+ format=DEFAULT_FORMAT,
+ )
+
+ params = [amp, x0, sigma, y0]
+
+ def fitfunc(x, params: list[float]) -> np.ndarray:
+ """Evaluate Planckian function with given parameters."""
+ return fitting.PlanckianFitComputer.evaluate(x, *params)
+
+ values = guifit(
+ x, y, fitfunc, params, parent=parent, wintitle=_("Planckian fit"), name=name
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "planckian", dict(zip(computer.get_params_names(), values)), y, y_fitted
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Two half-Gaussian fitting curve ------------------------------------------
+def twohalfgaussian_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute two half-Gaussian fit for asymmetric peaks
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ # Get initial parameter estimates from Sigima TwoHalfGaussianFitComputer
+ computer = fitting.TwoHalfGaussianFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ amp_left_guess = initial_params["amp_left"]
+ amp_right_guess = initial_params["amp_right"]
+ sigma_left_guess = initial_params["sigma_left"]
+ sigma_right_guess = initial_params["sigma_right"]
+ x0_guess = initial_params["x0"]
+ y0_left_guess = initial_params["y0_left"]
+ y0_right_guess = initial_params["y0_right"]
+
+ # Create parameter bounds
+ dx = np.max(x) - np.min(x)
+ dy = np.max(y) - np.min(y)
+
+ # Parameter bounds with better ranges
+ # New model signature: func(x, amp_left, amp_right, sigma_left,
+ # sigma_right, x0, y0_left, y0_right)
+ amp_left = FitParam(
+ _("Left amplitude"), amp_left_guess, dy * 0.1, dy * 3, format=DEFAULT_FORMAT
+ )
+ amp_right = FitParam(
+ _("Right amplitude"), amp_right_guess, dy * 0.1, dy * 3, format=DEFAULT_FORMAT
+ )
+ sigma_left = FitParam(
+ _("Left width") + " (σL)",
+ sigma_left_guess,
+ dx * 0.001, # Very small minimum
+ dx * 0.5, # Reasonable maximum
+ format=DEFAULT_FORMAT,
+ )
+ sigma_right = FitParam(
+ _("Right width") + " (σR)",
+ sigma_right_guess,
+ dx * 0.001, # Very small minimum
+ dx * 0.5, # Reasonable maximum
+ format=DEFAULT_FORMAT,
+ )
+ x0 = FitParam(
+ _("Center") + " (x₀)", x0_guess, np.min(x), np.max(x), format=DEFAULT_FORMAT
+ )
+ y0_left = FitParam(
+ _("Left baseline"),
+ y0_left_guess,
+ y0_left_guess - 0.2 * dy,
+ y0_left_guess + 0.2 * dy,
+ format=DEFAULT_FORMAT,
+ )
+ y0_right = FitParam(
+ _("Right baseline"),
+ y0_right_guess,
+ y0_right_guess - 0.2 * dy,
+ y0_right_guess + 0.2 * dy,
+ format=DEFAULT_FORMAT,
+ )
+
+ params = [amp_left, amp_right, sigma_left, sigma_right, x0, y0_left, y0_right]
+
+ def fitfunc(x, params):
+ return fitting.TwoHalfGaussianFitComputer.evaluate(x, *params)
+
+ values = guifit(
+ x,
+ y,
+ fitfunc,
+ params,
+ parent=parent,
+ wintitle=_("Two half-Gaussian fit"),
+ name=name,
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ fit_params = create_interactive_fit_params(
+ "twohalfgaussian",
+ dict(zip(computer.get_params_names(), values)),
+ y,
+ y_fitted,
+ )
+ return y_fitted, params, fit_params
+
+
+# --- Piecewise exponential (raise-decay) fitting curve ------------------------
+def piecewiseexponential_fit(x: np.ndarray, y: np.ndarray, parent=None, name=None):
+ """Compute piecewise exponential fit (raise-decay)
+
+ Returns (yfit, params, fit_params), where yfit is the fitted curve, params are
+ the fitting parameters and fit_params is the canonical metadata dictionary"""
+ # Get initial parameter estimates from Sigima DoubleExponentialFitComputer
+ computer = fitting.DoubleExponentialFitComputer(x, y)
+ initial_params = computer.compute_initial_params()
+ x_center_guess = initial_params["x_center"]
+ a_left_guess = initial_params["a_left"]
+ b_left_guess = initial_params["b_left"]
+ a_right_guess = initial_params["a_right"]
+ b_right_guess = initial_params["b_right"]
+ y0_guess = initial_params["y0"]
+
+ # Create parameter bounds
+ x_min, x_max = float(x.min()), float(x.max())
+ y_min, y_max = float(y.min()), float(y.max())
+ y_range = y_max - y_min
+ x_range = x_max - x_min
+
+ # Parameter bounds with more realistic ranges
+ # New model signature: func(x, x_center, a_left, b_left, a_right, b_right, y0)
+ # Amplitudes are bounded symmetrically: a `(0, guess * 10)` range silently
+ # inverts into an empty interval whenever the guess is negative.
+ amp_bound = max(abs(a_left_guess), abs(a_right_guess), y_range) * 10.0
+ # Rates are bounded symmetrically too: forcing b_left > 0 and b_right < 0
+ # assumes a rise-then-decay shape and makes the opposite shape unreachable.
+ rate_bound = 100.0 / x_range
+ x_center = FitParam(
+ _("Center position"), x_center_guess, x_min, x_max, format=DEFAULT_FORMAT
+ )
+ a_left = FitParam(
+ _("Left amplitude"),
+ a_left_guess,
+ -amp_bound,
+ amp_bound,
+ format=DEFAULT_FORMAT,
+ )
+ b_left = FitParam(
+ _("Left rate") + " (bL)",
+ b_left_guess, # Already in coefficient form
+ -rate_bound,
+ rate_bound,
+ format=DEFAULT_FORMAT,
+ )
+ a_right = FitParam(
+ _("Right amplitude"),
+ a_right_guess,
+ -amp_bound,
+ amp_bound,
+ format=DEFAULT_FORMAT,
+ )
+ b_right = FitParam(
+ _("Right rate") + " (bR)",
+ b_right_guess, # Already in coefficient form
+ -rate_bound,
+ rate_bound,
+ format=DEFAULT_FORMAT,
+ )
+ y0 = FitParam(
+ _("Base line"),
+ y0_guess,
+ y0_guess - 0.2 * y_range,
+ y0_guess + 0.2 * y_range,
+ format=DEFAULT_FORMAT,
+ )
+
+ params = [x_center, a_left, b_left, a_right, b_right, y0]
+
+ def fitfunc(x, params):
+ return fitting.DoubleExponentialFitComputer.evaluate(x, *params)
+
+ values = guifit(
+ x,
+ y,
+ fitfunc,
+ params,
+ parent=parent,
+ wintitle=_("Piecewise exponential (raise-decay) fit"),
+ name=name,
+ )
+ if values:
+ y_fitted = fitfunc(x, values)
+ # Sigima registers this model under "doubleexponential": the fit type is
+ # not derived from the dialog function name.
+ fit_params = create_interactive_fit_params(
+ "doubleexponential",
+ dict(zip(computer.get_params_names(), values)),
+ y,
+ y_fitted,
+ )
+ return y_fitted, params, fit_params
diff --git a/sigimax/widgets/h5browser.py b/sigimax/widgets/h5browser.py
new file mode 100644
index 0000000..d22f5b3
--- /dev/null
+++ b/sigimax/widgets/h5browser.py
@@ -0,0 +1,1061 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX HDF5 browser module
+
+.. autoclass:: H5Browser
+ :members:
+.. autoclass:: H5BrowserDialog
+ :members:
+.. autoclass:: H5TreeWidget
+ :members:
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+import abc
+import os
+import os.path as osp
+from typing import TYPE_CHECKING, Any, Callable
+
+from guidata.qthelpers import (
+ add_actions,
+ create_action,
+ create_toolbutton,
+ exec_dialog,
+ get_icon,
+ get_std_icon,
+ win32_fix_title_bar_background,
+)
+from guidata.utils.misc import to_string
+from guidata.widgets.arrayeditor import ArrayEditor
+from plotpy.builder import make
+from plotpy.plot import PlotOptions, PlotWidget
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+from qtpy.compat import getopenfilename
+from sigima import ImageObj, SignalObj
+
+from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object
+from sigimax.config import _, get_conf
+from sigimax.h5 import H5Importer
+from sigimax.utils.qthelpers import block_signals, qt_handle_error_message
+
+__all__ = [
+ "AbstractTreeWidget",
+ "H5Browser",
+ "H5BrowserDialog",
+ "H5FileSelector",
+ "H5TreeWidget",
+]
+
+if TYPE_CHECKING:
+ from plotpy.plot import BasePlot
+
+ from sigimax.h5.common import BaseNode
+
+
+class AbstractTreeWidgetMeta(type(QW.QTreeWidget), abc.ABCMeta):
+ """Mixed metaclass to avoid conflicts"""
+
+
+class AbstractTreeWidget(QW.QTreeWidget, metaclass=AbstractTreeWidgetMeta):
+ """One-column tree widget with context menu, ..."""
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ super().__init__(parent)
+ self.setItemsExpandable(True)
+ self.itemActivated.connect(self.activated)
+ self.itemClicked.connect(self.clicked)
+ # Setup context menu
+ self.menu = QW.QMenu(self)
+ self.collapse_all_action = None
+ self.collapse_selection_action = None
+ self.expand_all_action = None
+ self.expand_selection_action = None
+ self.common_actions = self.setup_common_actions()
+
+ self.itemSelectionChanged.connect(self.item_selection_changed)
+ self.item_selection_changed()
+
+ @abc.abstractmethod
+ def activated(self, item: QW.QTreeWidgetItem) -> None:
+ """Double-click event"""
+
+ @abc.abstractmethod
+ def clicked(self, item: QW.QTreeWidgetItem) -> None:
+ """Item was clicked"""
+
+ @abc.abstractmethod
+ def get_actions_from_items(
+ self, items: list[QW.QTreeWidgetItem]
+ ) -> list[QW.QAction]:
+ """Get actions from item"""
+ # Right here: add other actions if necessary (reimplement this method)
+ return []
+
+ def setup_common_actions(self) -> list[QW.QAction]:
+ """Setup context menu common actions"""
+ self.collapse_all_action = create_action(
+ self,
+ _("Collapse all"),
+ icon=get_icon("collapse.svg"),
+ triggered=self.collapseAll,
+ )
+ self.expand_all_action = create_action(
+ self, _("Expand all"), icon=get_icon("expand.svg"), triggered=self.expandAll
+ )
+ self.restore_action = create_action(
+ self,
+ _("Restore"),
+ tip=_("Restore original tree layout"),
+ icon=get_icon("restore.svg"),
+ triggered=self.restore,
+ )
+ self.collapse_selection_action = create_action(
+ self,
+ _("Collapse selection"),
+ icon=get_icon("collapse_selection.svg"),
+ triggered=self.collapse_selection,
+ )
+ self.expand_selection_action = create_action(
+ self,
+ _("Expand selection"),
+ icon=get_icon("expand_selection.svg"),
+ triggered=self.expand_selection,
+ )
+ return [
+ self.collapse_all_action,
+ self.expand_all_action,
+ self.restore_action,
+ None,
+ self.collapse_selection_action,
+ self.expand_selection_action,
+ ]
+
+ def update_menu(self) -> None:
+ """Update context menu"""
+ self.menu.clear()
+ items = self.selectedItems()
+ actions = self.get_actions_from_items(items)
+ if actions:
+ actions.append(None)
+ actions += self.common_actions
+ add_actions(self.menu, actions)
+
+ def restore(self) -> None:
+ """Restore tree state"""
+ self.collapseAll()
+ for item in self.get_top_level_items():
+ self.expandItem(item)
+
+ def __expand_item(self, item: QW.QTreeWidgetItem) -> None: # pragma: no cover
+ """Expand item tree branch"""
+ self.expandItem(item)
+ for index in range(item.childCount()):
+ child = item.child(index)
+ self.__expand_item(child)
+
+ def expand_selection(self) -> None: # pragma: no cover
+ """Expand selection"""
+ items = self.selectedItems()
+ if not items:
+ items = self.get_top_level_items()
+ for item in items:
+ self.__expand_item(item)
+ if items:
+ self.scrollToItem(items[0])
+
+ def __collapse_item(self, item: QW.QTreeWidgetItem) -> None: # pragma: no cover
+ """Collapse item tree branch"""
+ self.collapseItem(item)
+ for index in range(item.childCount()):
+ child = item.child(index)
+ self.__collapse_item(child)
+
+ def collapse_selection(self) -> None: # pragma: no cover
+ """Collapse selection"""
+ items = self.selectedItems()
+ if not items:
+ items = self.get_top_level_items()
+ for item in items:
+ self.__collapse_item(item)
+ if items:
+ self.scrollToItem(items[0])
+
+ def item_selection_changed(self) -> None:
+ """Item selection has changed"""
+ is_selection = len(self.selectedItems()) > 0
+ self.expand_selection_action.setEnabled(is_selection)
+ self.collapse_selection_action.setEnabled(is_selection)
+
+ def get_top_level_items(self) -> list[QW.QTreeWidgetItem]:
+ """Iterate over top level items"""
+ return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())]
+
+ def find_all_items(self):
+ """Find all items"""
+ return self.findItems("", QC.Qt.MatchContains | QC.Qt.MatchRecursive)
+
+ def contextMenuEvent(self, event: QG.QContextMenuEvent) -> None:
+ """Override Qt method"""
+ self.update_menu()
+ self.menu.popup(event.globalPos())
+
+
+class H5TreeWidget(AbstractTreeWidget):
+ """HDF5 Browser Tree Widget
+
+ Args:
+ parent: Parent widget
+ """
+
+ SIG_SELECTED = QC.Signal(QW.QTreeWidgetItem)
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ super().__init__(parent)
+ title = _("HDF5 Browser")
+ self.setColumnCount(4)
+ self.setWindowTitle(title)
+ self.setHeaderLabels([_("Name"), _("Size"), _("Type"), _("Value")])
+ self.header().setSectionResizeMode(0, QW.QHeaderView.Stretch)
+ self.header().setStretchLastSection(False)
+ self.fnames: list[str] = []
+ self.h5importers: list[H5Importer] = []
+
+ def add_root(self, fname: str) -> None:
+ """Add HDF5 root (new file)
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.fnames.append(osp.abspath(fname))
+ importer = H5Importer(fname)
+ self.h5importers.append(importer)
+ self.add_root_to_tree(importer)
+ # Temporarily expand all items to calculate proper column widths
+ rootitem = self.topLevelItem(self.topLevelItemCount() - 1)
+ self.expand_all_children(rootitem)
+ for col in range(4):
+ self.resizeColumnToContents(col)
+ # Restore to default state (only root and its immediate children expanded)
+ for index in range(rootitem.childCount()):
+ child = rootitem.child(index)
+ self.collapseItem(child)
+
+ def remove_root(self, fname: str) -> None:
+ """Remove HDF5 root
+
+ Args:
+ fname: HDF5 file name
+ """
+ index = self.fnames.index(osp.abspath(fname))
+ self.fnames.pop(index)
+ importer = self.h5importers.pop(index)
+ importer.close()
+ # Remove root item associated with file
+ item = self.topLevelItem(index)
+ self.takeTopLevelItem(index)
+ del item
+
+ def cleanup(self) -> None:
+ """Clean up widget"""
+ for importer in self.h5importers:
+ importer.close()
+ self.fnames: list[str] = []
+ self.h5importers: list[H5Importer] = []
+ self.clear()
+
+ def __get_top_level_item(self, item: QW.QTreeWidgetItem) -> QW.QTreeWidgetItem:
+ """Get top level item associated to item
+
+ Args:
+ item: Tree item
+
+ Returns:
+ Top level item
+ """
+ while item.parent():
+ item = item.parent()
+ return item
+
+ def get_node(self, item: QW.QTreeWidgetItem) -> BaseNode:
+ """Get HDF5 dataset associated to item
+
+ Args:
+ item: Tree item
+
+ Returns:
+ HDF5 node
+ """
+ toplevel_item = self.__get_top_level_item(item)
+ toplevel_index = self.indexOfTopLevelItem(toplevel_item)
+ node_id = item.data(0, QC.Qt.UserRole)
+ if node_id:
+ importer = self.h5importers[toplevel_index]
+ return importer.get(node_id)
+ return None
+
+ def get_nodes(self, only_checked_items: bool = True) -> list[BaseNode]:
+ """Get all nodes associated to checked items
+
+ Args:
+ only_checked_items: If True, only checked items are returned
+
+ Returns:
+ List of HDF5 nodes
+ """
+ datasets = []
+ for item in self.find_all_items():
+ if item.flags() & QC.Qt.ItemIsUserCheckable:
+ if only_checked_items and item.checkState(0) == 0:
+ continue
+ if item is not self.topLevelItem(0):
+ node = self.get_node(item)
+ datasets.append(node)
+ return datasets
+
+ def activated(self, item: QW.QTreeWidgetItem) -> None:
+ """Double-click event"""
+ if item is not self.topLevelItem(0):
+ self.SIG_SELECTED.emit(item)
+
+ def clicked(self, item: QW.QTreeWidgetItem) -> None:
+ """Click event"""
+ self.activated(item)
+
+ def get_actions_from_items(self, items): # pylint: disable=W0613
+ """Get actions from item"""
+ return []
+
+ def is_empty(self) -> bool:
+ """Return True if tree is empty"""
+ return len(self.find_all_items()) == 1
+
+ def is_any_item_checked(self) -> bool:
+ """Return True if any item is checked"""
+ for item in self.find_all_items():
+ if item.checkState(0) > 0:
+ return True
+ return False
+
+ def select_all(self, state: bool) -> None:
+ """Select all items
+
+ Args:
+ state: If True, all items are selected
+ """
+ for item in self.find_all_items():
+ if item.flags() & QC.Qt.ItemIsUserCheckable:
+ item.setSelected(state)
+ if state:
+ self.clicked(item)
+
+ def toggle_all(self, state: bool) -> None:
+ """Toggle all item state from 'unchecked' to 'checked'
+ (or vice-versa)
+
+ Args:
+ state: If True, all items are checked
+ """
+ for item in self.find_all_items():
+ if item.flags() & QC.Qt.ItemIsUserCheckable:
+ item.setCheckState(0, QC.Qt.Checked if state else QC.Qt.Unchecked)
+
+ @staticmethod
+ def __create_node(node: BaseNode) -> QW.QTreeWidgetItem:
+ """Create tree node from HDF5 node
+
+ Args:
+ node: HDF5 node
+
+ Returns:
+ Tree widget node
+ """
+ text = to_string(node.text)
+ if len(text) > 30:
+ text = text[:30] + "..."
+ treeitem = QW.QTreeWidgetItem([node.name, node.shape_str, node.dtype_str, text])
+ treeitem.setData(0, QC.Qt.UserRole, node.id)
+ if node.description:
+ for col in range(treeitem.columnCount()):
+ treeitem.setToolTip(col, node.description)
+ return treeitem
+
+ @staticmethod
+ def __recursive_popfunc(parent_item: QW.QTreeWidgetItem, node: BaseNode) -> None:
+ """Recursive HDF5 analysis
+
+ Args:
+ parent_item: Parent tree item
+ node: HDF5 node
+ """
+ tree_item = H5TreeWidget.__create_node(node)
+ if node.is_supported():
+ tree_item.setCheckState(0, QC.Qt.Unchecked)
+ else:
+ tree_item.setFlags(QC.Qt.ItemIsEnabled)
+ tree_item.setIcon(0, get_icon(node.icon_name))
+ parent_item.addChild(tree_item)
+ for child in node.children:
+ H5TreeWidget.__recursive_popfunc(tree_item, child)
+
+ def expand_all_children(self, item: QW.QTreeWidgetItem) -> None:
+ """Expand all children (recursively)
+
+ Args:
+ item: Tree item
+ """
+ self.expandItem(item)
+ for index in range(item.childCount()):
+ child = item.child(index)
+ self.expand_all_children(child)
+
+ def add_root_to_tree(self, importer: H5Importer) -> None:
+ """Add root to tree
+
+ Args:
+ importer: HDF5 importer
+ """
+ root = importer.root
+ rootitem = QW.QTreeWidgetItem([root.name])
+ rootitem.setToolTip(0, root.description)
+ rootitem.setData(0, QC.Qt.UserRole, root.id)
+ rootitem.setFlags(QC.Qt.ItemIsEnabled)
+ rootitem.setIcon(0, get_icon(root.icon_name))
+ self.addTopLevelItem(rootitem)
+ for node in root.children:
+ self.__recursive_popfunc(rootitem, node)
+ self.expandItem(rootitem)
+
+ def toggle_show_only_checkable_items(self, state: bool) -> None:
+ """Show only checkable items
+
+ Args:
+ state: If True, only checkable items are shown
+ """
+ for item in self.find_all_items():
+ item.setHidden(state)
+ if state:
+ # Iterate over checkable items and show them (and their parents)
+ for item in self.find_all_items():
+ if item.flags() & QC.Qt.ItemIsUserCheckable:
+ item.setHidden(False)
+ parent = item.parent()
+ while parent:
+ parent.setHidden(False)
+ parent = parent.parent()
+
+ def toggle_show_values(self, state: bool) -> None:
+ """Show values
+
+ Args:
+ state: If True, values are shown
+ """
+ # Hide or show the "Value" column
+ self.setColumnHidden(3, not state)
+
+ def set_current_file(self, fname: str) -> None:
+ """Set current file
+
+ Args:
+ fname: HDF5 file name
+ """
+ index = self.fnames.index(osp.abspath(fname))
+ item = self.topLevelItem(index)
+ self.setCurrentItem(item)
+ self.scrollToItem(item, QW.QAbstractItemView.PositionAtTop)
+
+
+class PlotPreview(QW.QStackedWidget):
+ """Plot preview"""
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ super().__init__(parent)
+ self.curvewidget = PlotWidget(
+ self,
+ options=PlotOptions(
+ type="curve",
+ curve_antialiasing=True,
+ show_axes_tab=False,
+ autoscale_margin_percent=get_conf().sig_autoscale_margin_percent.get(),
+ ),
+ )
+ self.addWidget(self.curvewidget)
+ self.imagewidget = PlotWidget(
+ self,
+ options=PlotOptions(
+ type="image",
+ show_contrast=True,
+ show_axes_tab=False,
+ autoscale_margin_percent=get_conf().ima_autoscale_margin_percent.get(),
+ ),
+ )
+ self.addWidget(self.imagewidget)
+
+ def cleanup(self) -> None:
+ """Clean up widget"""
+ for widget in (self.imagewidget, self.curvewidget):
+ widget.get_plot().del_all_items()
+
+ def update_plot_preview(self, node: BaseNode) -> None:
+ """Update plot preview widget"""
+ try:
+ obj = node.get_native_object()
+ except Exception as msg: # pylint: disable=broad-except
+ qt_handle_error_message(self, msg)
+ return
+ if obj is None:
+ # An error occurred while creating the object (invalid data, ...)
+ label = make.label(_("Unsupported data"), "C", (0, 0), "C")
+ plot: BasePlot = self.currentWidget().get_plot()
+ plot.del_all_items()
+ plot.add_item(label)
+ plot.replot()
+ return
+ if isinstance(obj, SignalObj):
+ obj: SignalObj
+ widget = self.curvewidget
+ else:
+ obj: ImageObj
+ widget = self.imagewidget
+ with CURVESTYLES.suspend():
+ item = create_adapter_from_object(obj).make_item()
+ plot = widget.get_plot()
+ plot.del_all_items()
+ plot.add_item(item)
+ plot.set_active_item(item)
+ item.unselect()
+ plot.do_autoscale()
+ self.setCurrentWidget(widget)
+
+
+class TablePreview(QW.QWidget):
+ """Table preview
+
+ Args:
+ title: Group title
+ parent: Parent widget
+ """
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ super().__init__(parent)
+ self.setLayout(QW.QVBoxLayout())
+ self.table = QW.QTableWidget(self)
+ self.table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
+ self.table.horizontalHeader().setStretchLastSection(True)
+ self.layout().addWidget(self.table)
+
+ def clear(self) -> None:
+ """Clear table"""
+ self.table.clear()
+
+ def update_table_preview(self, data: dict[str, Any]) -> None:
+ """Update table preview widget
+
+ Args:
+ node: HDF5 node
+ """
+ self.clear()
+ self.table.setRowCount(len(data))
+ self.table.setColumnCount(1)
+ self.table.setHorizontalHeaderLabels([_("Value")])
+ self.table.setVerticalHeaderLabels(list(data.keys()))
+ for row, value in enumerate(data.values()):
+ self.table.setItem(row, 0, QW.QTableWidgetItem(str(value)))
+ self.table.resizeRowsToContents()
+
+
+class GroupAndAttributes(QW.QTabWidget):
+ """Group and attributes
+
+ Args:
+ parent: Parent widget
+ show_array_callback: Callback to show array
+ """
+
+ def __init__(self, parent: QW.QWidget, show_array_callback: Callable) -> None:
+ super().__init__(parent)
+ self.group = TablePreview(self)
+ self.addTab(self.group, get_icon("h5group.svg"), _("Group"))
+ self.attrs = TablePreview(self)
+ self.addTab(self.attrs, get_icon("h5attrs.svg"), _("Attributes"))
+ # Add a button as corner widget to show the array (if any):
+ self.__show_array_btn = create_toolbutton(
+ self,
+ icon=get_icon("show_results.svg"),
+ text=_("Show array"),
+ autoraise=False,
+ triggered=show_array_callback,
+ )
+ self.__show_array_btn.setEnabled(False)
+ self.setCornerWidget(self.__show_array_btn, QC.Qt.TopRightCorner)
+
+ def cleanup(self) -> None:
+ """Clean up widget"""
+ self.group.clear()
+ self.attrs.clear()
+
+ def update_from_node(self, node: BaseNode) -> None:
+ """Update widget from node
+
+ Args:
+ node: HDF5 node
+ """
+ # Update group =================================================================
+ text = to_string(node.text)
+ if text:
+ lines = text.splitlines()[:5]
+ if len(lines) == 5:
+ lines += ["[...]"]
+ text = os.linesep.join(lines)
+ data = {
+ _("Path"): node.id,
+ _("Name"): node.name,
+ _("Description"): node.description,
+ _("Textual preview"): text,
+ # "Raw": repr(node.data),
+ }
+ self.group.update_table_preview(data)
+
+ # Update attributes ============================================================
+ self.attrs.update_table_preview(node.metadata)
+
+ # Update show array button =====================================================
+ self.__show_array_btn.setEnabled(node.IS_ARRAY)
+
+
+class H5FileSelector(QW.QWidget):
+ """HDF5 file selector
+
+ Args:
+ parent: Parent widget
+ """
+
+ SIG_ADD_FILENAME = QC.Signal(str)
+ SIG_REMOVE_FILENAME = QC.Signal(str)
+ SIG_CURRENT_CHANGED = QC.Signal(str)
+
+ def __init__(self, parent: QW.QWidget) -> None:
+ super().__init__(parent)
+ self.setLayout(QW.QHBoxLayout())
+ self.layout().setContentsMargins(0, 0, 0, 0)
+ self.combo = QW.QComboBox(self)
+ self.combo.currentTextChanged.connect(self.current_file_changed)
+ self.layout().addWidget(self.combo)
+ self.btn_add = create_toolbutton(
+ self,
+ icon=get_std_icon("DirOpenIcon"),
+ text=_("Open") + " ...",
+ autoraise=False,
+ triggered=lambda _checked=False: self.add_file(),
+ )
+ self.btn_add.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed)
+ self.layout().addWidget(self.btn_add)
+ self.btn_rmv = create_toolbutton(
+ self,
+ icon=get_std_icon("DialogCloseButton"),
+ text=_("Close"),
+ autoraise=False,
+ triggered=self.remove_file,
+ )
+ self.btn_rmv.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed)
+ self.layout().addWidget(self.btn_rmv)
+ self.btn_rmv.setEnabled(False)
+
+ def set_current_fname(self, fname: str) -> None:
+ """Set current file name
+
+ Args:
+ fname: HDF5 file name
+ """
+ index = self.combo.findText(fname)
+ if index >= 0:
+ self.combo.setCurrentIndex(index)
+
+ def get_current_fname(self) -> str:
+ """Return current file name
+
+ Returns:
+ HDF5 file name
+ """
+ return self.combo.currentText()
+
+ def current_file_changed(self, fname: str) -> None:
+ """Current file changed
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.SIG_CURRENT_CHANGED.emit(fname)
+
+ def add_fname(self, fname: str) -> None:
+ """Add file name
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.combo.addItem(get_icon("h5file.svg"), fname)
+ self.btn_rmv.setEnabled(True)
+
+ def remove_fname(self, fname: str) -> None:
+ """Remove file name
+
+ Args:
+ fname: HDF5 file name
+ """
+ index = self.combo.findText(fname)
+ if index >= 0:
+ self.combo.removeItem(index)
+ if self.combo.count() == 0:
+ self.btn_rmv.setEnabled(False)
+
+ def add_file(self, fname: str | None = None) -> None:
+ """Browse file
+
+ Args:
+ fname: HDF5 file name. Default is None.
+ (this is used for testing only)
+ """
+ if fname is None:
+ fname = getopenfilename(
+ self,
+ _("Select HDF5 file"),
+ "",
+ _("HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"),
+ )[0]
+ if fname:
+ self.SIG_ADD_FILENAME.emit(osp.abspath(fname))
+
+ def remove_file(self, fname: str | None = None) -> None:
+ """Remove file name
+
+ Args:
+ fname: HDF5 file name
+ """
+ if fname is None:
+ fname = self.combo.currentText()
+ self.SIG_REMOVE_FILENAME.emit(fname)
+
+
+class H5Browser(QW.QSplitter):
+ """HDF5 Browser Widget
+
+ Args:
+ parent: Parent widget
+ """
+
+ SIG_SELECT_NEW_FILE = QC.Signal(str)
+ SIG_REMOVE_FILE = QC.Signal(str)
+
+ def __init__(self, parent: QW.QWidget | None = None) -> None:
+ super().__init__(parent)
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ self.selector = H5FileSelector(self)
+ self.selector.SIG_ADD_FILENAME.connect(self.__add_new_file)
+ self.selector.SIG_REMOVE_FILENAME.connect(self.__remove_file)
+ self.selector.SIG_CURRENT_CHANGED.connect(self.__selector_current_file_changed)
+ self.tree = H5TreeWidget(self)
+ self.tree.SIG_SELECTED.connect(self.__item_selected_on_tree)
+ selectorandtree = QW.QFrame(self)
+ selectorandtree.setLayout(QW.QVBoxLayout())
+ selectorandtree.layout().addWidget(self.selector)
+ # Add toolbar with tree actions
+ toolbar = self.__create_toolbar()
+ selectorandtree.layout().addWidget(toolbar)
+ selectorandtree.layout().addWidget(self.tree)
+ selectorandtree.layout().setContentsMargins(0, 0, 0, 0)
+ self.addWidget(selectorandtree)
+ preview = QW.QSplitter(self)
+ preview.setOrientation(QC.Qt.Vertical)
+ self.addWidget(preview)
+ self.plotpreview = PlotPreview(self)
+ preview.addWidget(self.plotpreview)
+ self.groupandattrs = GroupAndAttributes(self, self.show_array)
+ preview.addWidget(self.groupandattrs)
+ preview.setSizes([int(self.size().height() / 2)] * 2)
+
+ def __create_toolbar(self) -> QW.QToolBar:
+ """Create toolbar with tree actions
+
+ Returns:
+ Toolbar widget
+ """
+ toolbar = QW.QToolBar(self)
+ toolbar.setToolButtonStyle(QC.Qt.ToolButtonTextBesideIcon)
+ toolbar.setIconSize(QC.QSize(16, 16))
+ toolbar.setStyleSheet("QToolBar { padding: 2px; spacing: 2px; }")
+ toolbar.addAction(self.tree.expand_all_action)
+ toolbar.addAction(self.tree.collapse_all_action)
+ toolbar.addAction(self.tree.restore_action)
+ toolbar.addSeparator()
+ toolbar.addAction(self.tree.expand_selection_action)
+ toolbar.addAction(self.tree.collapse_selection_action)
+ return toolbar
+
+ def open_file(self, fname: str) -> None:
+ """Open HDF5 file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.tree.add_root(fname)
+ self.selector.add_fname(fname)
+
+ def close_file(self, fname: str) -> None:
+ """Close HDF5 file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.tree.remove_root(fname)
+ self.selector.remove_fname(fname)
+
+ def __add_new_file(self, fname: str) -> None:
+ """Add new file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.open_file(fname)
+ self.selector.set_current_fname(fname)
+ self.SIG_SELECT_NEW_FILE.emit(fname)
+
+ def __remove_file(self, fname: str) -> None:
+ """Remove file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.close_file(fname)
+ self.SIG_REMOVE_FILE.emit(fname)
+
+ def cleanup(self) -> None:
+ """Clean up widget"""
+ self.tree.cleanup()
+ self.plotpreview.cleanup()
+
+ def get_node(self, item: QW.QTreeWidgetItem | None = None) -> BaseNode:
+ """Return (selected) dataset
+
+ Args:
+ item: Tree item
+
+ Returns:
+ HDF5 node
+ """
+ if item is None:
+ item = self.tree.currentItem()
+ return self.tree.get_node(item)
+
+ def __item_selected_on_tree(self, item: QW.QTreeWidgetItem) -> None:
+ """Item selected on tree
+
+ Args:
+ item: Tree item
+ """
+ # View the selected item
+ node = self.get_node(item)
+ if node.is_supported():
+ self.plotpreview.update_plot_preview(node)
+ self.groupandattrs.update_from_node(node)
+ # Update the file selector combo box
+ with block_signals(self.selector.combo):
+ # Avoid triggering current file changed signal, which would result in
+ # loosing the current selection on the tree (side effect: "Show array"
+ # button would still be enabled if the previous node was an array, except
+ # that now the current node is not an array, thus causing an error if
+ # the user clicks on the button).
+ self.selector.set_current_fname(node.h5file.filename)
+
+ def __selector_current_file_changed(self, fname: str) -> None:
+ """Selector current file changed
+
+ Args:
+ fname: HDF5 file name
+ """
+ if fname:
+ self.tree.set_current_file(fname)
+
+ def show_array(self) -> None:
+ """Show array"""
+ node = self.get_node()
+ assert node.IS_ARRAY
+ arrayeditor = ArrayEditor(self)
+ arrayeditor.setup_and_check(
+ node.data, title=node.name, readonly=True, add_title_suffix=False
+ )
+ exec_dialog(arrayeditor)
+
+
+class H5BrowserDialog(QW.QDialog):
+ """HDF5 Browser Dialog
+
+ Args:
+ parent: Parent widget
+ size: Dialog size
+ """
+
+ def __init__(
+ self, parent: QW.QWidget | None = None, size: tuple[int, int] = (1150, 700)
+ ) -> None:
+ super().__init__(parent)
+ self.setWindowFlags(QC.Qt.Window)
+ self.setObjectName("h5browser")
+ self.setWindowTitle(_("HDF5 Browser"))
+ self.setWindowIcon(get_icon("h5browser.svg"))
+ win32_fix_title_bar_background(self)
+ vlayout = QW.QVBoxLayout()
+ self.setLayout(vlayout)
+ self.button_layout: QW.QHBoxLayout | None = None
+ self.bbox: QW.QDialogButtonBox | None = None
+ self.nodes: list[BaseNode] = []
+ self.checkbox_show_only: QW.QCheckBox | None = None
+ self.checkbox_show_values: QW.QCheckBox | None = None
+
+ self.browser = H5Browser(self)
+ self.browser.SIG_SELECT_NEW_FILE.connect(self.select_new_file)
+ self.browser.SIG_REMOVE_FILE.connect(self.remove_file)
+ vlayout.addWidget(self.browser)
+
+ self.browser.tree.itemChanged.connect(lambda item: self.refresh_buttons())
+
+ self.install_button_layout()
+
+ self.setMinimumSize(QC.QSize(900, 500))
+ self.resize(QC.QSize(*size))
+ self.browser.setSizes([int(self.size().height() / 2)] * 2)
+ self.refresh_buttons()
+
+ def accept(self) -> None:
+ """Accept changes"""
+ self.nodes = self.browser.tree.get_nodes()
+ QW.QDialog.accept(self)
+
+ def is_empty(self) -> bool:
+ """Return True if tree is empty"""
+ return self.browser.tree.is_empty()
+
+ def cleanup(self) -> None:
+ """Cleanup dialog"""
+ self.browser.cleanup()
+
+ def refresh_buttons(self) -> None:
+ """Refresh buttons"""
+ state = self.browser.tree.is_any_item_checked()
+ self.bbox.button(QW.QDialogButtonBox.Ok).setEnabled(state)
+
+ def show_only_checkable_items(self, state: int) -> None:
+ """Show only checkable items
+
+ Args:
+ state: If True, only checkable items are shown
+ """
+ self.browser.tree.toggle_show_only_checkable_items(state)
+ fname = self.browser.selector.get_current_fname()
+ if fname:
+ self.browser.tree.set_current_file(fname)
+
+ def __finalize_setup(self) -> None:
+ """Finalize setup"""
+ tree = self.browser.tree
+ tree.toggle_show_only_checkable_items(self.checkbox_show_only.isChecked())
+ tree.toggle_show_values(self.checkbox_show_values.isChecked())
+
+ def open_file(self, fname: str) -> None:
+ """Open file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.browser.open_file(fname)
+ self.__finalize_setup()
+
+ def open_files(self, fnames: list[str]) -> None:
+ """Open files
+
+ Args:
+ fnames: HDF5 file names
+ """
+ for fname in fnames:
+ self.browser.open_file(fname)
+ self.__finalize_setup()
+
+ def select_new_file(self, fname: str) -> None: # pylint:disable=unused-argument
+ """Select new file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.__finalize_setup()
+ self.refresh_buttons()
+
+ def remove_file(self, fname: str) -> None: # pylint:disable=unused-argument
+ """Remove file
+
+ Args:
+ fname: HDF5 file name
+ """
+ self.refresh_buttons()
+
+ def get_all_nodes(self) -> list[BaseNode]:
+ """Return all supported datasets
+
+ Returns:
+ List of HDF5 nodes
+ """
+ return self.browser.tree.get_nodes(only_checked_items=False)
+
+ def get_nodes(self) -> list[BaseNode]:
+ """Return datasets
+
+ Returns:
+ List of HDF5 nodes
+ """
+ return self.nodes
+
+ def install_button_layout(self) -> None:
+ """Install button layout"""
+ bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel)
+ bbox.accepted.connect(self.accept)
+ bbox.rejected.connect(self.reject)
+
+ btn_check_all = create_toolbutton(
+ self,
+ icon=get_icon("check_all.svg"),
+ text=_("Check all"),
+ autoraise=False,
+ shortcut=QG.QKeySequence.SelectAll,
+ triggered=lambda checked=True: self.browser.tree.toggle_all(checked),
+ )
+ btn_uncheck_all = create_toolbutton(
+ self,
+ icon=get_icon("uncheck_all.svg"),
+ text=_("Uncheck all"),
+ autoraise=False,
+ triggered=lambda checked=False: self.browser.tree.toggle_all(checked),
+ )
+ self.checkbox_show_only = QW.QCheckBox(_("Show only supported data"))
+ self.checkbox_show_only.stateChanged.connect(self.show_only_checkable_items)
+ self.checkbox_show_values = QW.QCheckBox(_("Show values"))
+ self.checkbox_show_values.stateChanged.connect(
+ self.browser.tree.toggle_show_values
+ )
+
+ self.button_layout = QW.QHBoxLayout()
+ self.button_layout.addWidget(self.checkbox_show_only)
+ self.button_layout.addWidget(self.checkbox_show_values)
+ self.button_layout.addSpacing(10)
+ self.button_layout.addWidget(btn_check_all)
+ self.button_layout.addWidget(btn_uncheck_all)
+ self.button_layout.addStretch()
+ self.button_layout.addWidget(bbox)
+ self.bbox = bbox
+
+ vlayout: QW.QVBoxLayout = self.layout()
+ vlayout.addSpacing(10)
+ vlayout.addLayout(self.button_layout)
diff --git a/sigimax/widgets/imagebackground.py b/sigimax/widgets/imagebackground.py
new file mode 100644
index 0000000..19bab80
--- /dev/null
+++ b/sigimax/widgets/imagebackground.py
@@ -0,0 +1,128 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Image background selection dialog.
+
+.. autoclass:: ImageBackgroundDialog
+ :members:
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from guidata.configtools import get_icon
+from plotpy.builder import make
+from plotpy.plot import PlotDialog, PlotOptions
+
+from sigimax.adapters_plotpy import create_adapter_from_object
+from sigimax.config import _, get_conf
+from sigimax.utils.qthelpers import resize_widget_to_parent
+
+__all__ = [
+ "ImageBackgroundDialog",
+]
+
+if TYPE_CHECKING:
+ from plotpy.items import MaskedXYImageItem, RangeComputation2d, RectangleShape
+ from qtpy.QtWidgets import QWidget
+ from sigima.objects import ImageObj
+
+
+class ImageBackgroundDialog(PlotDialog):
+ """Image background selection dialog.
+
+ Args:
+ image: image object
+ parent: parent widget. Defaults to None.
+ options: plot options. Defaults to None.
+ """
+
+ def __init__(
+ self,
+ image: ImageObj,
+ parent: QWidget | None = None,
+ options: PlotOptions | dict[str, Any] | None = None,
+ ) -> None:
+ self.__background: float | None = None
+ self.__rect_coords: tuple[float, float, float, float] | None = None
+ self.imageitem: MaskedXYImageItem | None = None
+ self.rectarea: RectangleShape | None = None
+ self.comput2d: RangeComputation2d | None = None
+ super().__init__(
+ title=_("Image background selection"),
+ edit=True,
+ parent=parent,
+ options=options,
+ )
+ self.setObjectName("backgroundselection")
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ else:
+ resize_widget_to_parent(self, aspect_ratio=1.0)
+ self.__image = image.copy()
+ self.__setup_dialog()
+
+ def test_compute_background(self) -> None:
+ """Method to test background computation."""
+ # Instead of waiting for Qt events, directly test the computation method
+ # by simulating what RangeComputation2d would do
+ x0, y0, x1, y1 = self.rectarea.get_rect()
+ x, y, z = self.imageitem.get_data(x0, y0, x1, y1)
+ self.__compute_background(x, y, z)
+
+ def __compute_background(
+ self,
+ x: np.ndarray, # pylint: disable=unused-argument
+ y: np.ndarray, # pylint: disable=unused-argument
+ z: np.ndarray,
+ ) -> float:
+ """Compute background value"""
+ self.__rect_coords = self.rectarea.get_rect()
+ self.__background = z.mean()
+ return self.__background
+
+ def __setup_dialog(self) -> None:
+ """Setup dialog box"""
+ obj = self.__image
+ self.imageitem = create_adapter_from_object(obj).make_item()
+ plot = self.get_plot()
+ if obj.is_uniform_coords:
+ x0, y0 = obj.x0, obj.y0
+ x1, y1 = obj.xc + obj.dx, obj.yc + obj.dy
+ else:
+ x0, y0 = obj.xcoords[0], obj.ycoords[0]
+ xc = (obj.xcoords[0] + obj.xcoords[-1]) / 2
+ yc = (obj.ycoords[0] + obj.ycoords[-1]) / 2
+ x1, y1 = xc, yc
+ self.rectarea = make.rectangle(x0, y0, x1, y1, _("Background area"))
+ self.comput2d = make.computation2d(
+ self.rectarea,
+ "TL",
+ _("Background value:") + " %g",
+ self.imageitem,
+ self.__compute_background,
+ )
+ for item in (self.imageitem, self.rectarea, self.comput2d):
+ plot.add_item(item)
+ plot.replot()
+ plot.set_active_item(self.rectarea)
+
+ def get_background(self) -> float:
+ """Get background value"""
+ return self.__background
+
+ def get_rect_coords(self) -> tuple[float, float, float, float]:
+ """Get rectangle coordinates
+
+ Returns:
+ tuple: rectangle coordinates (x0, y0, x1, y1)
+
+ Raises:
+ ValueError: if rectangle coordinates are not set
+ """
+ if self.__rect_coords is None:
+ raise ValueError("Rectangle coordinates not set")
+ return self.__rect_coords
diff --git a/sigimax/widgets/logviewer.py b/sigimax/widgets/logviewer.py
new file mode 100644
index 0000000..fd2978a
--- /dev/null
+++ b/sigimax/widgets/logviewer.py
@@ -0,0 +1,94 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Module providing a log viewer widget, a log viewer window and SigimaX's log viewer
+
+.. autoclass:: LogViewerWindow
+ :members:
+.. autofunction:: get_log_filenames
+.. autofunction:: get_log_prompt_message
+.. autofunction:: exec_sigimax_logviewer_dialog
+"""
+
+from __future__ import annotations
+
+import os.path as osp
+
+from guidata.configtools import get_icon
+from guidata.qthelpers import exec_dialog
+from qtpy import QtWidgets as QW
+
+from sigimax.config import _, get_conf, get_old_log_fname
+from sigimax.env import execenv
+from sigimax.widgets.fileviewer import FileViewerWidget, get_title_contents
+
+__all__ = [
+ "LogViewerWindow",
+ "exec_sigimax_logviewer_dialog",
+ "get_log_filenames",
+ "get_log_prompt_message",
+]
+
+
+class LogViewerWindow(QW.QDialog):
+ """Log viewer window"""
+
+ def __init__(self, fnames: list[str], parent: QW.QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.setObjectName("logviewer")
+ self.setWindowTitle(get_conf().app_name.get() + " - " + _("Log files"))
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ self.tabs = QW.QTabWidget(self)
+ for fname in fnames:
+ if osp.isfile(fname):
+ title, contents = get_title_contents(fname)
+ if not contents.strip():
+ continue
+ viewer = FileViewerWidget(language="Python")
+ viewer.set_data(title, contents)
+ self.tabs.addTab(viewer, get_icon("logs.svg"), osp.basename(fname))
+ layout = QW.QVBoxLayout()
+ layout.addWidget(self.tabs)
+ self.setLayout(layout)
+ self.resize(900, 400)
+
+ @property
+ def is_empty(self) -> bool:
+ """Return True if there is no log available"""
+ return self.tabs.count() == 0
+
+
+def get_log_filenames() -> list[str]:
+ """Return log filenames"""
+ conf = get_conf()
+ return [
+ conf.traceback_log_path.get(),
+ conf.faulthandler_log_path.get(),
+ get_old_log_fname(conf.traceback_log_path.get()),
+ get_old_log_fname(conf.faulthandler_log_path.get()),
+ ]
+
+
+def get_log_prompt_message() -> str | None:
+ """Return prompt message for log files, i.e. a message informing the user
+ whether log files were generated during last session or current session."""
+ avail = [osp.isfile(fname) for fname in get_log_filenames()]
+ if avail[0] or avail[1]:
+ return _("Log files were generated during current session.")
+ if avail[2] or avail[3]:
+ return _("Log files were generated during last session.")
+ return None
+
+
+def exec_sigimax_logviewer_dialog(parent: QW.QWidget | None = None) -> None:
+ """View SigimaX logs"""
+ fnames = [osp.normpath(fname) for fname in get_log_filenames() if osp.isfile(fname)]
+ dlg = LogViewerWindow(fnames, parent=parent)
+ if dlg.is_empty:
+ if not execenv.unattended:
+ QW.QMessageBox.information(
+ dlg, get_conf().app_name.get(), _("Log files are currently empty.")
+ )
+ dlg.close()
+ else:
+ exec_dialog(dlg)
diff --git a/sigimax/widgets/plotdock.py b/sigimax/widgets/plotdock.py
new file mode 100644
index 0000000..fb68dd8
--- /dev/null
+++ b/sigimax/widgets/plotdock.py
@@ -0,0 +1,413 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Docks
+=====
+
+The :mod:`sigimax.widgets.plotdock` module provides the dockable plot widgets
+for the SigimaX main window.
+
+Plot widget
+-----------
+
+.. autoclass:: SigimaXPlotWidget
+
+Dockable plot widget
+--------------------
+
+.. autoclass:: DockablePlotWidget
+"""
+
+from __future__ import annotations
+
+__all__ = [
+ "CurveStatsToolFunctions",
+ "DockablePlotWidget",
+ "SigimaXPlotWidget",
+]
+
+import warnings
+from typing import TYPE_CHECKING
+
+import numpy as np
+from guidata.qthelpers import is_dark_theme
+from guidata.widgets.dockable import DockableWidget
+from plotpy.constants import PlotType
+from plotpy.plot import PlotOptions, PlotWidget
+from plotpy.tools import (
+ BasePlotMenuTool,
+ CurveStatsTool,
+ DeleteItemTool,
+ DisplayCoordsTool,
+ DoAutoscaleTool,
+ EditItemDataTool,
+ ExportItemDataTool,
+ ImageStatsTool,
+ ItemCenterTool,
+ RectangularSelectionTool,
+ RectZoomTool,
+ SelectTool,
+ YRangeCursorTool,
+)
+from plotpy.tools.image import get_stats as get_image_stats
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+from qtpy.QtWidgets import QApplication
+from sigima.tools.signal import pulse
+from skimage import measure
+
+from sigimax.config import get_conf
+
+if TYPE_CHECKING:
+ from plotpy.items.image.base import BaseImageItem
+ from plotpy.plot import BasePlot
+ from plotpy.styles import BaseImageParam
+
+
+class CurveStatsToolFunctions:
+ """Statistical functions for `CurveStatsTool` and `YRangeCursorTool`"""
+
+ @classmethod
+ def set_labelfuncs(cls, statstool: CurveStatsTool | YRangeCursorTool) -> None:
+ """Set label functions for the statistics tool"""
+ if isinstance(statstool, CurveStatsTool):
+ labelfuncs = list(CurveStatsTool.LABELFUNCS)
+ labelfuncs[-1] = (labelfuncs[-1][0] + "
", labelfuncs[-1][1])
+ labelfuncs.extend(
+ [
+ ("FWHM=%s", cls.fwhm_info),
+ ("∆xRISE 10-90=%s", cls.rise_time_info),
+ (
+ "∆xRISE 20-80=%s",
+ lambda x, y: cls.rise_time_info(x, y, 0.2, 0.8),
+ ),
+ ("∆xFALL 90-10=%s", cls.fall_time_info),
+ (
+ "∆xFALL 80-20=%s",
+ lambda x, y: cls.fall_time_info(x, y, 0.8, 0.2),
+ ),
+ ]
+ )
+ statstool.set_labelfuncs(tuple(labelfuncs))
+ else: # YRangeCursorTool - use PlotPy's defaults as-is
+ statstool.set_labelfuncs(YRangeCursorTool.LABELFUNCS)
+
+ @staticmethod
+ def fwhm_info(x, y):
+ """Return FWHM information string"""
+ try:
+ with warnings.catch_warnings(record=True) as w:
+ x0, _y0, x1, _y1 = pulse.fwhm(x, y, "zero-crossing")
+ wstr = " ⚠️" if w else ""
+ except (ValueError, ZeroDivisionError, pulse.InvalidSignalError):
+ return "🛑"
+ return f"{x1 - x0:g}{wstr}"
+
+ @staticmethod
+ def rise_time_info(x, y, start_ratio=0.1, end_ratio=0.9):
+ """Return rise time information string"""
+ try:
+ with warnings.catch_warnings(record=True) as w:
+ dt = pulse.get_rise_time(x, y, start_ratio, end_ratio)
+ wstr = " ⚠️" if w else ""
+ if dt is None:
+ return "🛑"
+ except (ValueError, ZeroDivisionError, pulse.InvalidSignalError):
+ return "🛑"
+ return f"{dt:g}{wstr}"
+
+ @staticmethod
+ def fall_time_info(x, y, start_ratio=0.9, end_ratio=0.1):
+ """Return fall time information string"""
+ try:
+ with warnings.catch_warnings(record=True) as w:
+ dt = pulse.get_fall_time(x, y, start_ratio, end_ratio)
+ wstr = " ⚠️" if w else ""
+ if dt is None:
+ return "🛑"
+ except (ValueError, ZeroDivisionError, pulse.InvalidSignalError):
+ return "🛑"
+ return f"{dt:g}{wstr}"
+
+
+def get_more_image_stats(
+ item: BaseImageItem,
+ x0: float,
+ y0: float,
+ x1: float,
+ y1: float,
+) -> str:
+ """Return formatted string with stats on image rectangular area
+ (output should be compatible with AnnotatedShape.get_info)
+
+ Args:
+ item: image item
+ x0: X0
+ y0: Y0
+ x1: X1
+ y1: Y1
+ """
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", UserWarning)
+ info = get_image_stats(item, x0, y0, x1, y1)
+
+ ix0, iy0, ix1, iy1 = item.get_closest_index_rect(x0, y0, x1, y1)
+ data = item.data[iy0:iy1, ix0:ix1]
+ p: BaseImageParam = item.param
+ xunit, yunit, zunit = p.get_units()
+
+ integral = np.nansum(data)
+ integral_fmt = r"%.3e " + zunit
+ info += f"
∑ = {integral_fmt % integral}"
+
+ if xunit == yunit:
+ surfacefmt = p.xformat.split()[0] + " " + xunit
+ if xunit != "":
+ surfacefmt = surfacefmt + "²"
+ surface = abs((x1 - x0) * (y1 - y0))
+ info += f"
A = {surfacefmt % surface}"
+ if xunit is not None and zunit is not None:
+ if surface != 0:
+ density = integral / surface
+ densityfmt = r"%.3e"
+ if xunit and zunit:
+ densityfmt += " " + zunit + "/" + xunit + "²"
+ info = info + f"
ρ = {densityfmt % density}"
+ # Convert data (ndarray) to a simple array to compute centroid with the new
+ # einsum optimisation introduce in numpy 2.4.0 and scikit-image 0.26.0
+ c_i, c_j = measure.centroid(np.array(data))
+ c_x, c_y = item.get_plot_coordinates(c_j + ix0, c_i + iy0)
+ info += "
" + "
".join(
+ [
+ "C|x = " + p.xformat % c_x,
+ "C|y = " + p.yformat % c_y,
+ ]
+ )
+
+ return info
+
+
+class SigimaXPlotWidget(PlotWidget):
+ """SigimaX PlotWidget
+
+ This class is a subclass of `plotpy.plot.PlotWidget` that provides a
+ customized widget for SigimaX, with a specific set of tools and a
+ customized appearance.
+
+ Args:
+ plot_type: Plot type
+ """
+
+ def __init__(self, plot_type: PlotType) -> None:
+ # Get autoscale margin from configuration based on plot type
+ conf = get_conf()
+ if plot_type == PlotType.CURVE:
+ autoscale_margin = conf.sig_autoscale_margin_percent.get()
+ elif plot_type == PlotType.IMAGE:
+ autoscale_margin = conf.ima_autoscale_margin_percent.get()
+ else:
+ # For AUTO or MANUAL types, use signal margin as default
+ autoscale_margin = conf.sig_autoscale_margin_percent.get()
+
+ super().__init__(
+ options=PlotOptions(
+ type=plot_type,
+ show_axes_tab=False,
+ autoscale_margin_percent=autoscale_margin,
+ ),
+ toolbar=True,
+ )
+
+ def __register_standard_tools(self) -> None:
+ """Register standard tools
+
+ The only differences with the `manager.register_standard_tools` method are
+ the following:
+
+ 1. We don't register the `BasePlotMenuTool, "axes"` tool, because it is not
+ compatible with SigimaX's apps approach to axes management.
+ 2. We don't register the `ItemListPanelTool` tool (this intends to prevent
+ the user from accessing the item list panel, and thus, the parameters of all
+ the items - some of them are read-only and should not be modified, like the
+ annotations for example).
+ """
+ mgr = self.manager
+ select_tool = mgr.add_tool(SelectTool)
+ mgr.set_default_tool(select_tool)
+ mgr.add_tool(RectangularSelectionTool, intersect=False)
+ mgr.add_tool(RectZoomTool)
+ mgr.add_tool(DoAutoscaleTool)
+ mgr.add_tool(BasePlotMenuTool, "item")
+ mgr.add_tool(ExportItemDataTool)
+ mgr.add_tool(EditItemDataTool)
+ mgr.add_tool(ItemCenterTool)
+ mgr.add_tool(DeleteItemTool)
+ mgr.add_separator_tool()
+ mgr.add_tool(BasePlotMenuTool, "grid")
+ mgr.add_tool(DisplayCoordsTool)
+
+ def __register_other_tools(self) -> None:
+ """Register other tools"""
+ mgr = self.manager
+ mgr.add_separator_tool()
+ if self.options.type == PlotType.CURVE:
+ mgr.register_curve_tools()
+ xstatstool = mgr.get_tool(CurveStatsTool)
+ CurveStatsToolFunctions.set_labelfuncs(xstatstool)
+ ystatstool = mgr.get_tool(YRangeCursorTool)
+ CurveStatsToolFunctions.set_labelfuncs(ystatstool)
+ else:
+ mgr.register_image_tools()
+ # Customizing the ImageStatsTool
+ statstool = mgr.get_tool(ImageStatsTool)
+ statstool.set_stats_func(get_more_image_stats, replace=True)
+ self._customize_image_panels()
+
+ mgr.add_separator_tool()
+ mgr.register_other_tools()
+ mgr.add_separator_tool()
+ mgr.update_tools_status()
+ mgr.get_default_tool().activate()
+
+ def _customize_image_panels(self) -> None:
+ """Customize the X and Y cross section panels.
+
+ Called once the image tools are registered, so that the panels and their
+ toolbars exist. The base implementation is a no-op.
+ """
+
+ def register_tools(self) -> None:
+ """Register the plotting tools according to the plot type"""
+ self.__register_standard_tools()
+ self.__register_other_tools()
+
+
+# Mapping from config string to Qt dock area constant
+_DOCK_LOCATION_MAP: dict[str, QC.Qt.DockWidgetArea] = {
+ "top": QC.Qt.TopDockWidgetArea,
+ "bottom": QC.Qt.BottomDockWidgetArea,
+ "left": QC.Qt.LeftDockWidgetArea,
+ "right": QC.Qt.RightDockWidgetArea,
+}
+
+
+class DockablePlotWidget(DockableWidget):
+ """Docked plotting widget
+
+ Args:
+ parent: Parent widget
+ plot_type: Plot type
+ """
+
+ LOCATION = QC.Qt.RightDockWidgetArea
+
+ #: Plot widget class instantiated by this dock: override in subclasses to
+ #: provide an application-specific one.
+ PLOTWIDGET_CLASS: type[SigimaXPlotWidget] = SigimaXPlotWidget
+
+ def __init__(
+ self,
+ parent: QW.QWidget,
+ plot_type: PlotType,
+ ) -> None:
+ super().__init__(parent)
+ self._apply_dock_location()
+ self.plotwidget = self.PLOTWIDGET_CLASS(plot_type)
+ self.toolbar = self.plotwidget.get_toolbar()
+ self.watermark: QW.QLabel | None = None
+ self._setup_watermark()
+ self.setup_layout()
+ self.setup_plotwidget()
+
+ def _apply_dock_location(self) -> None:
+ """Set dock location from config."""
+ location_str = get_conf().plot_dock_location.get()
+ location = _DOCK_LOCATION_MAP.get(location_str, QC.Qt.RightDockWidgetArea)
+ self.setup_dockwidget(location=location)
+
+ def _setup_watermark(self) -> None:
+ """Create the watermark label from the configured image path.
+
+ If ``Conf.watermark_image_path`` is empty, no watermark is created.
+ """
+ path = get_conf().watermark_image_path.get()
+ if path:
+ self.watermark = QW.QLabel()
+ pixmap = QG.QPixmap(path)
+ self.watermark.setPixmap(pixmap)
+ else:
+ self.watermark = None
+
+ def __get_toolbar_row_col(self) -> tuple[int, int]:
+ """Return toolbar row and column"""
+ tb_pos = get_conf().plot_toolbar_position.get()
+ tb_col, tb_row = 1, 1
+ if tb_pos in ("left", "right"):
+ self.toolbar.setOrientation(QC.Qt.Vertical)
+ tb_col = 0 if tb_pos == "left" else 2
+ else:
+ self.toolbar.setOrientation(QC.Qt.Horizontal)
+ tb_row = 0 if tb_pos == "top" else 2
+ return tb_row, tb_col
+
+ def setup_layout(self) -> None:
+ """Setup layout"""
+ tb_row, tb_col = self.__get_toolbar_row_col()
+ layout = QW.QGridLayout()
+ layout.addWidget(self.toolbar, tb_row, tb_col)
+ layout.addWidget(self.plotwidget, 1, 1)
+ if self.watermark is not None:
+ layout.addWidget(self.watermark, 1, 1, QC.Qt.AlignCenter)
+ self.setLayout(layout)
+
+ def update_toolbar_position(self) -> None:
+ """Update toolbar position"""
+ tb_row, tb_col = self.__get_toolbar_row_col()
+ layout = self.layout()
+ layout.removeWidget(self.toolbar)
+ layout.addWidget(self.toolbar, tb_row, tb_col)
+
+ def setup_plotwidget(self) -> None:
+ """Setup plotting widget"""
+ title = self.toolbar.windowTitle()
+ self.plotwidget.get_manager().add_toolbar(self.toolbar, title)
+ # Customizing widget appearances
+ self.update_color_mode()
+ plot = self.plotwidget.get_plot()
+ canvas = plot.canvas()
+ canvas.setFrameStyle(canvas.Plain | canvas.NoFrame)
+ if self.watermark is not None:
+ plot.SIG_ITEMS_CHANGED.connect(self.update_watermark)
+
+ def update_color_mode(self) -> None:
+ """Update plot widget styles according to application color mode"""
+ if is_dark_theme():
+ palette = QApplication.instance().palette()
+ else:
+ palette = QG.QPalette(QC.Qt.white)
+ for widget in (self.plotwidget, self.plotwidget.get_plot(), self):
+ widget.setBackgroundRole(QG.QPalette.Window)
+ widget.setAutoFillBackground(True)
+ widget.setPalette(palette)
+
+ def get_plot(self) -> BasePlot:
+ """Return plot instance"""
+ return self.plotwidget.get_plot()
+
+ def update_watermark(self, plot: BasePlot) -> None:
+ """Update watermark visibility"""
+ if self.watermark is None:
+ return
+ items = plot.get_items()
+ if self.plotwidget.options.type == PlotType.IMAGE:
+ enabled = len(items) <= 1
+ else:
+ enabled = len(items) <= 2
+ self.watermark.setVisible(enabled)
+
+ # ------DockableWidget API
+ def visibility_changed(self, enable: bool) -> None:
+ """DockWidget visibility has changed"""
+ DockableWidget.visibility_changed(self, enable)
+ self.toolbar.setVisible(enable)
diff --git a/sigimax/widgets/signalbaseline.py b/sigimax/widgets/signalbaseline.py
new file mode 100644
index 0000000..7050622
--- /dev/null
+++ b/sigimax/widgets/signalbaseline.py
@@ -0,0 +1,98 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Signal base line selection dialog.
+
+.. autoclass:: SignalBaselineDialog
+ :members:
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+from guidata.configtools import get_icon
+from plotpy.builder import make
+from plotpy.plot import PlotDialog
+
+from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object
+from sigimax.config import _, get_conf
+from sigimax.utils.qthelpers import resize_widget_to_parent
+
+__all__ = [
+ "SignalBaselineDialog",
+]
+
+if TYPE_CHECKING:
+ from plotpy.items import CurveItem, Marker, XRangeSelection
+ from qtpy.QtWidgets import QWidget
+ from sigima.objects import SignalObj
+
+
+class SignalBaselineDialog(PlotDialog):
+ """Signal baseline selection dialog.
+
+ Args:
+ signal: signal object
+ parent: parent widget. Defaults to None.
+ """
+
+ def __init__(self, signal: SignalObj, parent: QWidget | None = None) -> None:
+ self.__curve_styles = CURVESTYLES.style_generator()
+ self.__baseline: float | None = None
+ self.__x_range: tuple[float, float] = [np.nan, np.nan]
+ self.curve: CurveItem | None = None
+ self.cursor: Marker | None = None
+ self.xrange: XRangeSelection | None = None
+ super().__init__(title=_("Signal baseline selection"), edit=True, parent=parent)
+ self.setObjectName("baselineselection")
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ legend = make.legend("TR")
+ self.get_plot().add_item(legend)
+ self.__signal = signal.copy()
+ self.__setup_dialog()
+ resize_widget_to_parent(self, aspect_ratio=1.0)
+
+ def __setup_dialog(self) -> None:
+ """Setup dialog box"""
+ obj = self.__signal
+ with CURVESTYLES.alternative(self.__curve_styles):
+ self.curve = create_adapter_from_object(obj).make_item()
+ plot = self.get_plot()
+ plot.set_antialiasing(True)
+ plot.SIG_RANGE_CHANGED.connect(self.xrange_changed)
+ plot.SIG_MARKER_CHANGED.connect(self.cursor_changed)
+ self.cursor = make.hcursor(0.0, _("Base line") + " = %g")
+ self.cursor.set_movable(False)
+ self.xrange = make.xrange(obj.x[0], obj.x[int(0.2 * len(obj.x))])
+ for item in (self.curve, self.cursor, self.xrange):
+ plot.add_item(item)
+ plot.replot()
+ plot.set_active_item(self.xrange)
+ self.xrange_changed(self.xrange, *self.xrange.get_range())
+
+ # pylint: disable=unused-argument
+ def xrange_changed(self, item: XRangeSelection, xmin: float, xmax: float) -> None:
+ """X range changed"""
+ self.__x_range = sorted([xmin, xmax])
+ imin, imax = np.searchsorted(self.__signal.x, self.__x_range)
+ if imin == imax:
+ return
+ self.cursor.set_pos(0, np.mean(self.__signal.y[imin:imax]))
+ plot = self.get_plot()
+ plot.replot()
+
+ def cursor_changed(self, item: Marker) -> None:
+ """Cursor changed"""
+ _x, self.__baseline = item.get_pos()
+
+ def get_baseline(self) -> float:
+ """Get baseline"""
+ return self.__baseline
+
+ def get_x_range(self) -> tuple[float, float]:
+ """Get x range"""
+ return self.__x_range
diff --git a/sigimax/widgets/signalcursor.py b/sigimax/widgets/signalcursor.py
new file mode 100644
index 0000000..11b7dc0
--- /dev/null
+++ b/sigimax/widgets/signalcursor.py
@@ -0,0 +1,221 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Signal horizontal or vertical cursor selection dialog.
+
+.. autoclass:: SignalCursorDialog
+ :members:
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Literal
+
+import numpy as np
+from guidata.configtools import get_icon
+from plotpy.builder import make
+from plotpy.plot import PlotDialog
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+from sigima.tools.signal.features import find_x_values_at_y
+
+from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object
+from sigimax.config import _, get_conf
+from sigimax.utils.qthelpers import block_signals, resize_widget_to_parent
+
+__all__ = [
+ "SignalCursorDialog",
+]
+
+if TYPE_CHECKING:
+ from plotpy.items import CurveItem, Marker
+ from qtpy.QtWidgets import QWidget
+ from sigima.objects import SignalObj
+
+
+class SignalCursorDialog(PlotDialog):
+ """Signal horizontal or vertical cursor selection dialog.
+
+ Args:
+ signal: signal object
+ parent: parent widget. Defaults to None.
+ """
+
+ def __init__(
+ self,
+ signal: SignalObj,
+ cursor_orientation: Literal["horizontal", "vertical"],
+ parent: QWidget | None = None,
+ ) -> None:
+ assert cursor_orientation in (
+ "horizontal",
+ "vertical",
+ ), "cursor_orientation must be 'horizontal' or 'vertical'"
+ self.__curve_styles = CURVESTYLES.style_generator()
+ self.__cursor_orientation = cursor_orientation
+ self.__signal = signal
+ self.__x_value: float | None = None
+ self.__y_value: float | None = None
+ self.curve: CurveItem | None = None
+ self.hcursor: Marker | None = None
+ self.vcursor: Marker | None = None
+ self.xlineedit: QW.QLineEdit | None = None
+ self.ylineedit: QW.QLineEdit | None = None
+ if cursor_orientation == "horizontal":
+ title = _("Select X value with cursor")
+ else:
+ title = _("Select Y value with cursor")
+ super().__init__(title=title, edit=True, parent=parent)
+ self.setObjectName("SignalCursorDialog")
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ legend = make.legend("TR")
+ self.get_plot().add_item(legend)
+ self.__setup_dialog()
+ resize_widget_to_parent(self, aspect_ratio=1.0)
+
+ def __setup_dialog(self) -> None:
+ """Setup dialog box"""
+ apply_button = QW.QPushButton(_("Apply"))
+ apply_button.setIcon(get_icon("apply.svg"))
+ apply_button.setToolTip(_("Apply cursor position"))
+ xlabel = QW.QLabel("X=")
+ ylabel = QW.QLabel("Y=")
+ self.xlineedit = QW.QLineEdit()
+ self.xlineedit.editingFinished.connect(self.xlineedit_editing_finished)
+ x_validator = QG.QDoubleValidator()
+ x_validator.setLocale(QC.QLocale("C"))
+ self.xlineedit.setValidator(x_validator)
+ self.ylineedit = QW.QLineEdit()
+ self.ylineedit.editingFinished.connect(self.ylineedit_editing_finished)
+ y_validator = QG.QDoubleValidator()
+ y_validator.setLocale(QC.QLocale("C"))
+ self.ylineedit.setValidator(y_validator)
+ self.xlineedit.setReadOnly(self.__cursor_orientation == "horizontal")
+ self.xlineedit.setDisabled(self.__cursor_orientation == "horizontal")
+ self.ylineedit.setReadOnly(self.__cursor_orientation == "vertical")
+ self.ylineedit.setDisabled(self.__cursor_orientation == "vertical")
+ xygroup = QW.QGroupBox(_("Cursor position"))
+ xylayout = QW.QHBoxLayout()
+ xylayout.addWidget(xlabel)
+ xylayout.addWidget(self.xlineedit)
+ if self.__cursor_orientation == "vertical":
+ xylayout.addWidget(apply_button)
+ apply_button.clicked.connect(self.xlineedit_editing_finished)
+ xylayout.addStretch()
+ xylayout.addSpacing(10)
+ xylayout.addWidget(ylabel)
+ xylayout.addWidget(self.ylineedit)
+ if self.__cursor_orientation == "horizontal":
+ xylayout.addWidget(apply_button)
+ apply_button.clicked.connect(self.ylineedit_editing_finished)
+ xygroup.setLayout(xylayout)
+ self.button_layout.insertWidget(0, xygroup)
+
+ obj = self.__signal
+ with CURVESTYLES.alternative(self.__curve_styles):
+ self.curve = create_adapter_from_object(obj).make_item()
+ plot = self.get_plot()
+ plot.set_antialiasing(True)
+
+ xcursor = make.xcursor(np.mean(obj.x), np.mean(obj.y), "X = %g, Y = %g")
+ xcursor.set_selectable(False)
+ param = xcursor.markerparam
+ param.symbol.facecolor = "blue"
+ param.symbol.edgecolor = "cyan"
+ param.symbol.size = 9
+ param.line.style = "DotLine"
+ param.line.color = "blue"
+ param.line.width = 2.0
+ param.update_item(xcursor)
+
+ plot.SIG_MARKER_CHANGED.connect(self.cursor_changed)
+ if self.__cursor_orientation == "horizontal":
+ self.hcursor = make.hcursor(np.mean(obj.y), "Y = %g")
+ self.vcursor = xcursor
+ self.vcursor.setVisible(False)
+ else:
+ self.vcursor = make.vcursor(np.mean(obj.x), "X = %g")
+ self.hcursor = xcursor
+ self.hcursor.setVisible(False)
+ for item in (self.curve, self.vcursor, self.hcursor):
+ plot.add_item(item)
+ plot.replot()
+ if self.__cursor_orientation == "horizontal":
+ plot.set_active_item(self.hcursor)
+ self.cursor_changed(self.hcursor)
+ else:
+ plot.set_active_item(self.vcursor)
+ self.cursor_changed(self.vcursor)
+
+ def cursor_changed(self, item: Marker) -> None:
+ """Cursor changed"""
+ sig = self.__signal
+ plot = self.get_plot()
+ if self.__cursor_orientation == "horizontal" and item is self.hcursor:
+ _x, y = item.get_pos()
+ x = None
+ x_values = find_x_values_at_y(sig.x, sig.y, y)
+ if len(x_values) > 0:
+ x = x_values[0]
+ with block_signals(plot):
+ self.vcursor.set_pos(x, y)
+ self.vcursor.setVisible(x is not None)
+ self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(x is not None)
+ elif self.__cursor_orientation == "vertical" and item is self.vcursor:
+ x, _y = item.get_pos()
+ y_index = np.searchsorted(self.__signal.x, x)
+ if x < self.__signal.x[0] or y_index >= len(self.__signal.y):
+ y = None
+ else:
+ y = self.__signal.y[y_index]
+ with block_signals(plot):
+ self.hcursor.set_pos(x, y)
+ self.hcursor.setVisible(True)
+ self.hcursor.setVisible(y is not None)
+ self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(y is not None)
+ self.xlineedit.setText(f"{x:g}" if x is not None else "")
+ self.ylineedit.setText(f"{y:g}" if y is not None else "")
+ self.__x_value, self.__y_value = x, y
+
+ def xlineedit_editing_finished(self) -> None:
+ """X line edit editing finished"""
+ try:
+ x = float(self.xlineedit.text())
+ _x, y = self.vcursor.get_pos()
+ if self.__cursor_orientation == "horizontal":
+ self.hcursor.set_pos(x, y)
+ else:
+ self.vcursor.set_pos(x, y)
+ except ValueError:
+ pass
+ plot = self.get_plot()
+ plot.replot()
+
+ def ylineedit_editing_finished(self) -> None:
+ """Y line edit editing finished"""
+ try:
+ y = float(self.ylineedit.text())
+ x, _y = self.hcursor.get_pos()
+ if self.__cursor_orientation == "horizontal":
+ self.hcursor.set_pos(x, y)
+ else:
+ self.vcursor.set_pos(x, y)
+ except ValueError:
+ pass
+ plot = self.get_plot()
+ plot.replot()
+
+ def get_cursor_position(self) -> tuple[float, float]:
+ """Get cursor position"""
+ return self.__x_value, self.__y_value
+
+ def get_x_value(self) -> float:
+ """Get cursor x value"""
+ return self.__x_value
+
+ def get_y_value(self) -> float:
+ """Get cursor y value"""
+ return self.__y_value
diff --git a/sigimax/widgets/signaldeltax.py b/sigimax/widgets/signaldeltax.py
new file mode 100644
index 0000000..8e1b26d
--- /dev/null
+++ b/sigimax/widgets/signaldeltax.py
@@ -0,0 +1,161 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+GUI dialog for analyzing signals and calculating full width at Y.
+
+.. autoclass:: SignalDeltaXDialog
+ :members:
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+import warnings
+from typing import TYPE_CHECKING
+
+import numpy as np
+from guidata.configtools import get_icon
+from plotpy.builder import make
+from plotpy.plot import PlotDialog
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+from sigima.tools.signal.pulse import full_width_at_y
+
+from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object
+from sigimax.config import _, get_conf
+from sigimax.utils.qthelpers import resize_widget_to_parent
+
+__all__ = [
+ "SignalDeltaXDialog",
+]
+
+if TYPE_CHECKING:
+ from plotpy.items import CurveItem, Marker, XRangeSelection
+ from qtpy.QtWidgets import QWidget
+ from sigima.objects import SignalObj
+
+
+class SignalDeltaXDialog(PlotDialog):
+ """Signal Delta X dialog.
+
+ Args:
+ signal: signal object
+ parent: parent widget. Defaults to None.
+ """
+
+ def __init__(self, signal: SignalObj, parent: QWidget | None = None) -> None:
+ self.__curve_styles = CURVESTYLES.style_generator()
+ self.__signal = signal
+ self.__coords: list[float, float, float, float] | None = None
+ self.curve: CurveItem | None = None
+ self.hcursor: Marker | None = None
+ self.delta_xrange: XRangeSelection | None = None
+ self.deltaxlineedit: QW.QLineEdit | None = None
+ self.ylineedit: QW.QLineEdit | None = None
+ title = _("Select Y value with cursor")
+ super().__init__(title=title, edit=True, parent=parent)
+ self.setObjectName("SignalCursorDialog")
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ legend = make.legend("TR")
+ self.get_plot().add_item(legend)
+ self.__setup_dialog()
+ resize_widget_to_parent(self, aspect_ratio=1.0)
+
+ def __setup_dialog(self) -> None:
+ """Setup dialog box"""
+ apply_button = QW.QPushButton(_("Apply"))
+ apply_button.setIcon(get_icon("apply.svg"))
+ apply_button.setToolTip(_("Apply cursor position"))
+ xlabel = QW.QLabel("∆X=")
+ ylabel = QW.QLabel("Y=")
+ self.deltaxlineedit = QW.QLineEdit()
+ self.deltaxlineedit.setReadOnly(True)
+ self.deltaxlineedit.setDisabled(True)
+ self.ylineedit = QW.QLineEdit()
+ self.ylineedit.editingFinished.connect(self.ylineedit_editing_finished)
+ y_validator = QG.QDoubleValidator()
+ y_validator.setLocale(QC.QLocale("C"))
+ self.ylineedit.setValidator(y_validator)
+ xygroup = QW.QGroupBox(_("Cursor position"))
+ xylayout = QW.QHBoxLayout()
+ xylayout.addWidget(xlabel)
+ xylayout.addWidget(self.deltaxlineedit)
+ xylayout.addWidget(ylabel)
+ xylayout.addWidget(self.ylineedit)
+ xylayout.addWidget(apply_button)
+ vlayout = QW.QVBoxLayout()
+ vlayout.addLayout(xylayout)
+ self.warning_label = QW.QLabel()
+ vlayout.addWidget(self.warning_label)
+ apply_button.clicked.connect(self.ylineedit_editing_finished)
+ xygroup.setLayout(vlayout)
+ self.button_layout.insertWidget(0, xygroup)
+
+ obj = self.__signal
+ with CURVESTYLES.alternative(self.__curve_styles):
+ self.curve = create_adapter_from_object(obj).make_item()
+ plot = self.get_plot()
+ plot.set_antialiasing(True)
+
+ self.delta_xrange = make.xrange(0.0, 1.0)
+ self.delta_xrange.setVisible(False)
+ self.delta_xrange.set_style("roi", "s/readonly")
+ self.delta_xrange.set_selectable(False)
+
+ plot.SIG_MARKER_CHANGED.connect(self.cursor_changed)
+ self.hcursor = make.hcursor(np.mean(obj.y), "Y = %g")
+ for item in (self.curve, self.delta_xrange, self.hcursor):
+ plot.add_item(item)
+ plot.replot()
+ plot.set_active_item(self.hcursor)
+ self.cursor_changed(self.hcursor)
+
+ def cursor_changed(self, item: Marker) -> None:
+ """Cursor changed"""
+ sig = self.__signal
+ _x, y = item.get_pos()
+
+ try:
+ with warnings.catch_warnings(record=True) as w:
+ self.__coords = full_width_at_y(sig.x, sig.y, y)
+ if np.nan in self.__coords:
+ raise ValueError("Invalid coordinates")
+ delta_str = f"{self.__coords[2] - self.__coords[0]:g}"
+ ok = True
+ if len(w) > 0:
+ self.warning_label.setText("⚠️ " + str(w[-1].message))
+ else:
+ self.warning_label.setText("")
+ self.delta_xrange.setVisible(True)
+ self.delta_xrange.set_range(self.__coords[0], self.__coords[2])
+ except ValueError:
+ delta_str = ""
+ ok = False
+ self.delta_xrange.setVisible(False)
+
+ self.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(ok)
+ self.deltaxlineedit.setText(delta_str)
+ self.ylineedit.setText(f"{y:g}" if y is not None else "")
+
+ def ylineedit_editing_finished(self) -> None:
+ """Y line edit editing finished"""
+ try:
+ y = float(self.ylineedit.text())
+ x, _y = self.hcursor.get_pos()
+ self.hcursor.set_pos(x, y)
+ except ValueError:
+ pass
+ plot = self.get_plot()
+ plot.replot()
+
+ def get_coords(self) -> tuple[float, float, float, float]:
+ """Return coordinates of segment associated to the width at Y"""
+ return self.__coords
+
+ def get_y_value(self) -> float:
+ """Get cursor y value"""
+ _x, y = self.hcursor.get_pos()
+ return y
diff --git a/sigimax/widgets/signalpeak.py b/sigimax/widgets/signalpeak.py
new file mode 100644
index 0000000..84cec42
--- /dev/null
+++ b/sigimax/widgets/signalpeak.py
@@ -0,0 +1,207 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""Signal peak detection feature
+
+.. autoclass:: SignalPeakDetectionDialog
+ :members:
+"""
+
+# pylint: disable=invalid-name # Allows short reference names like x, y, ...
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import numpy as np
+from guidata.configtools import get_icon
+from plotpy.builder import make
+from plotpy.plot import PlotDialog
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+from sigima.tools.signal.peakdetection import peak_indices
+
+from sigimax.adapters_plotpy import CURVESTYLES, create_adapter_from_object
+from sigimax.config import _, get_conf
+from sigimax.utils.qthelpers import resize_widget_to_parent
+
+__all__ = [
+ "SignalPeakDetectionDialog",
+]
+
+if TYPE_CHECKING:
+ from plotpy.items import Marker
+ from qtpy.QtWidgets import QWidget
+ from sigima.objects import SignalObj
+
+
+class DistanceSlider(QW.QWidget):
+ """Minimum distance slider
+
+ Args:
+ parent: parent widget. Defaults to None.
+ """
+
+ TITLE = _("Minimum distance:")
+ SIG_VALUE_CHANGED = QC.Signal(int)
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+ self.slider = QW.QSlider(QC.Qt.Horizontal)
+ self.label = QW.QLabel()
+ layout = QW.QHBoxLayout()
+ layout.addWidget(self.label)
+ layout.addWidget(self.slider)
+ self.setLayout(layout)
+
+ def value_changed(self, value: int) -> None:
+ """Slider value has changed
+
+ Args:
+ value: slider value
+ """
+ plural = "s" if value > 1 else ""
+ self.label.setText(f"{self.TITLE} {value} point{plural}")
+ self.SIG_VALUE_CHANGED.emit(value)
+
+ def setup_slider(self, value: int, maxval: int) -> None:
+ """Setup slider
+
+ Args:
+ value: initial value
+ maxval: maximum value
+ """
+ self.slider.setMinimum(1)
+ self.slider.setMaximum(maxval)
+ self.slider.setValue(value)
+ self.slider.setTickPosition(QW.QSlider.TicksBothSides)
+ self.value_changed(value)
+ self.slider.valueChanged.connect(self.value_changed)
+
+
+class SignalPeakDetectionDialog(PlotDialog):
+ """Signal Peak detection dialog
+
+ Args:
+ signal: signal object
+ parent: parent widget. Defaults to None.
+ """
+
+ def __init__(self, signal: SignalObj, parent: QWidget | None = None) -> None:
+ self.__curve_styles = CURVESTYLES.style_generator()
+ self.peaks = None
+ self.peak_indices = None
+ self.in_curve = None
+ self.in_threshold = None
+ self.in_threshold_cursor = None
+ self.co_results = None
+ self.co_positions = None
+ self.co_markers = None
+ self.min_distance = None
+ self.distance_slider: DistanceSlider | None = None
+ super().__init__(title=_("Signal peak detection"), edit=True, parent=parent)
+ self.setObjectName("peakdetection")
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ legend = make.legend("TR")
+ self.get_plot().add_item(legend)
+ self.__signal = signal.copy()
+ self.__setup_dialog()
+ resize_widget_to_parent(self, parent, aspect_ratio=1.0)
+
+ def populate_plot_layout(self) -> None: # Reimplement PlotDialog method
+ """Populate the plot layout"""
+ super().populate_plot_layout()
+ self.distance_slider = DistanceSlider(self)
+ self.add_widget(self.distance_slider, 1, 0, 1, 1)
+
+ def __setup_dialog(self) -> None:
+ """Setup dialog box"""
+ obj = self.__signal
+ with CURVESTYLES.alternative(self.__curve_styles):
+ self.in_curve = create_adapter_from_object(obj).make_item()
+ plot = self.get_plot()
+ plot.set_antialiasing(True)
+ plot.add_item(self.in_curve)
+ self.in_threshold = 0.5 * (np.max(obj.y) - np.min(obj.y)) + np.min(obj.y)
+ cursor = make.hcursor(self.in_threshold)
+ self.in_threshold_cursor = cursor
+ plot.add_item(self.in_threshold_cursor)
+ self.co_results = make.label("", "TL", (0, 0), "TL")
+ plot.add_item(self.co_results)
+ plot.SIG_MARKER_CHANGED.connect(self.hcursor_changed)
+ self.min_distance = 1
+ self.distance_slider.setup_slider(self.min_distance, len(obj.y) // 4)
+ self.distance_slider.SIG_VALUE_CHANGED.connect(self.minimum_distance_changed)
+ self.compute_peaks()
+ # Replot, otherwise, it's not possible to set active item:
+ plot.replot()
+ plot.set_active_item(cursor)
+
+ def get_peaks(self) -> list[tuple[float, float]]:
+ """Return peaks coordinates"""
+ return self.peaks
+
+ def get_peak_indices(self) -> list[int]:
+ """Return peak indices"""
+ return self.peak_indices
+
+ def get_threshold(self) -> float:
+ """Return relative threshold"""
+ y = self.__signal.y
+ return (self.in_threshold - np.min(y)) / (np.max(y) - np.min(y))
+
+ def get_min_dist(self) -> int:
+ """Return minimum distance"""
+ return self.min_distance
+
+ def compute_peaks(self) -> None:
+ """Compute peak detection"""
+ x, y = self.__signal.xydata
+ plot = self.get_plot()
+ self.peak_indices = peak_indices(
+ y,
+ thres=self.in_threshold,
+ min_dist=self.min_distance,
+ thres_abs=True,
+ )
+ self.peaks = [(x[index], y[index]) for index in self.peak_indices]
+ markers = [
+ make.marker(
+ pos,
+ movable=False,
+ color="orange",
+ markerstyle="|",
+ linewidth=1,
+ marker="NoSymbol",
+ linestyle="DashLine",
+ )
+ for pos in self.peaks
+ ]
+ if self.co_markers is not None:
+ plot.del_items(self.co_markers)
+ self.co_markers = markers
+ for item in self.co_markers:
+ plot.add_item(item)
+ positions = [str(marker.get_pos()[0]) for marker in markers]
+ prefix = f"{_('Peaks:')}
"
+ self.co_results.set_text(prefix + "
".join(positions))
+
+ def hcursor_changed(self, marker: Marker) -> None:
+ """Horizontal cursor position has changed
+
+ Args:
+ marker: marker item
+ """
+ _x, y = marker.get_pos()
+ self.in_threshold = y
+ self.compute_peaks()
+
+ def minimum_distance_changed(self, value: int) -> None:
+ """Minimum distance changed
+
+ Args:
+ value: minimum distance value
+ """
+ self.min_distance = value
+ self.compute_peaks()
+ self.get_plot().replot()
diff --git a/sigimax/widgets/splashscreen.py b/sigimax/widgets/splashscreen.py
new file mode 100644
index 0000000..89767fa
--- /dev/null
+++ b/sigimax/widgets/splashscreen.py
@@ -0,0 +1,234 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Splash screen
+=============
+
+The :mod:`sigimax.widgets.splashscreen` module provides a configurable splash
+screen for SigimaX-derived applications.
+
+Derived applications can customize the splash screen by providing a
+:class:`SplashScreenConfig` instance, or by subclassing
+:class:`SigimaXSplashScreen` for advanced rendering.
+
+Basic usage::
+
+ from sigimax.widgets.splashscreen import SplashScreenConfig, SigimaXSplashScreen
+
+ config = SplashScreenConfig(
+ image_path="path/to/splash.png",
+ app_name="MyApp",
+ app_version="1.0.0",
+ tagline="Scientific Data Processing",
+ )
+ splash = SigimaXSplashScreen(config)
+ splash.show()
+ splash.show_message("Loading modules...")
+ # ... heavy initialization ...
+ splash.finish(main_window)
+
+Factory from global configuration::
+
+ splash = SigimaXSplashScreen.from_conf()
+ if splash is not None:
+ splash.show()
+ # ...
+ splash.finish(main_window)
+
+.. autoclass:: SplashScreenConfig
+.. autoclass:: SigimaXSplashScreen
+"""
+
+from __future__ import annotations
+
+import dataclasses
+from typing import TYPE_CHECKING
+
+from guidata.configtools import get_image_file_path
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+if TYPE_CHECKING:
+ pass
+
+__all__ = [
+ "SigimaXSplashScreen",
+ "SplashScreenConfig",
+]
+
+
+@dataclasses.dataclass
+class SplashScreenConfig:
+ """Configuration for a splash screen.
+
+ All fields are optional except *image_path*. When *image_path* is empty
+ or ``None``, no splash screen is shown.
+
+ Args:
+ image_path: Absolute or relative path to the splash image (PNG, SVG,
+ or any format supported by :class:`QPixmap`). If empty or ``None``,
+ the splash screen is disabled.
+ app_name: Application name overlaid on the splash image.
+ app_version: Application version overlaid on the splash image.
+ tagline: Optional subtitle displayed below the version.
+ show_progress: If ``True``, progress messages are displayed at the
+ bottom of the splash screen via :meth:`SigimaXSplashScreen.show_message`.
+ text_color: Color used for overlay text (default: white).
+ text_alignment: Qt alignment flags for overlay text
+ (default: bottom-left).
+ """
+
+ image_path: str | None = None
+ app_name: str = ""
+ app_version: str = ""
+ tagline: str = ""
+ show_progress: bool = True
+ text_color: QG.QColor = dataclasses.field(
+ default_factory=lambda: QG.QColor("white")
+ )
+ text_alignment: QC.Qt.AlignmentFlag = dataclasses.field(
+ default_factory=lambda: QC.Qt.AlignBottom | QC.Qt.AlignLeft
+ )
+
+ @property
+ def is_enabled(self) -> bool:
+ """Return ``True`` if the splash screen should be shown."""
+ return bool(self.image_path)
+
+ @classmethod
+ def from_conf(cls) -> SplashScreenConfig:
+ """Build a :class:`SplashScreenConfig` from the global
+ :data:`sigimax.config.CONF` options.
+
+ Returns:
+ Configuration instance populated from global options.
+ """
+ # Import here to avoid circular imports
+ from sigimax.config import get_conf # pylint: disable=import-outside-toplevel
+
+ conf = get_conf()
+
+ return cls(
+ image_path=conf.splash_image_path.get() or None,
+ app_name=conf.app_name.get(),
+ app_version=conf.app_version.get(),
+ tagline=conf.app_desc.get(),
+ show_progress=conf.splash_show_progress.get(),
+ )
+
+
+class SigimaXSplashScreen(QW.QSplashScreen):
+ """Configurable splash screen for SigimaX-derived applications.
+
+ Creates a :class:`QSplashScreen` from a :class:`SplashScreenConfig`.
+ If the configuration specifies an application name/version, they are
+ painted as overlay text on top of the splash image.
+
+ Args:
+ config: Splash screen configuration. If ``None``, a default
+ configuration is built from :data:`sigimax.config.CONF`.
+ """
+
+ def __init__(self, config: SplashScreenConfig | None = None) -> None:
+ self._config = config or SplashScreenConfig.from_conf()
+ pixmap = self._load_pixmap()
+ super().__init__(pixmap, QC.Qt.WindowStaysOnTopHint)
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ def show_message(self, message: str) -> None:
+ """Display a progress message on the splash screen.
+
+ The message is shown only if :attr:`SplashScreenConfig.show_progress`
+ is ``True``.
+
+ Args:
+ message: The progress message to display.
+ """
+ if self._config.show_progress:
+ self.showMessage(
+ message,
+ int(self._config.text_alignment),
+ self._config.text_color,
+ )
+ # Process events so the message is actually painted
+ QW.QApplication.processEvents()
+
+ @classmethod
+ def from_conf(cls) -> SigimaXSplashScreen | None:
+ """Factory: build a splash screen from the global configuration.
+
+ Returns:
+ A :class:`SigimaXSplashScreen` instance, or ``None`` if the
+ configuration does not specify a splash image.
+ """
+ config = SplashScreenConfig.from_conf()
+ if not config.is_enabled:
+ return None
+ return cls(config)
+
+ # ------------------------------------------------------------------
+ # Internal helpers
+ # ------------------------------------------------------------------
+
+ def _load_pixmap(self) -> QG.QPixmap:
+ """Load the splash image as a :class:`QPixmap`.
+
+ Returns:
+ The loaded pixmap. If the image cannot be loaded, a minimal
+ fallback pixmap is generated.
+ """
+ path = self._config.image_path or ""
+ pixmap = QG.QPixmap(path)
+ if pixmap.isNull() and path:
+ try:
+ resolved_path = get_image_file_path(path, default=None)
+ except RuntimeError:
+ resolved_path = ""
+ pixmap = QG.QPixmap(resolved_path)
+ if pixmap.isNull():
+ pixmap = self._create_fallback_pixmap()
+ return pixmap
+
+ def _create_fallback_pixmap(self) -> QG.QPixmap:
+ """Create a minimal fallback pixmap when no image is available.
+
+ Returns:
+ A 480x280 pixmap with the application name drawn on a dark
+ background.
+ """
+ width, height = 480, 280
+ pixmap = QG.QPixmap(width, height)
+ pixmap.fill(QG.QColor(40, 40, 40))
+
+ painter = QG.QPainter(pixmap)
+ painter.setPen(self._config.text_color)
+
+ # Application name
+ font = painter.font()
+ font.setPointSize(24)
+ font.setBold(True)
+ painter.setFont(font)
+ name = self._config.app_name or "SigimaX"
+ painter.drawText(
+ QC.QRect(0, 0, width, height),
+ int(QC.Qt.AlignCenter),
+ name,
+ )
+
+ # Version
+ if self._config.app_version:
+ font.setPointSize(12)
+ font.setBold(False)
+ painter.setFont(font)
+ painter.drawText(
+ QC.QRect(0, height // 2 + 20, width, 40),
+ int(QC.Qt.AlignHCenter | QC.Qt.AlignTop),
+ f"v{self._config.app_version}",
+ )
+
+ painter.end()
+ return pixmap
diff --git a/sigimax/widgets/status.py b/sigimax/widgets/status.py
new file mode 100644
index 0000000..4a61f15
--- /dev/null
+++ b/sigimax/widgets/status.py
@@ -0,0 +1,205 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX main window status bar widgets
+
+.. autoclass:: BaseStatus
+ :members:
+.. autoclass:: ConsoleStatus
+ :members:
+.. autoclass:: MemoryStatus
+ :members:
+"""
+
+from __future__ import annotations
+
+import os
+
+import psutil
+from guidata.configtools import get_icon
+from guidata.qthelpers import get_std_icon
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+
+from sigimax.config import DEBUG, _, get_conf
+from sigimax.env import execenv
+
+__all__ = [
+ "BaseStatus",
+ "ConsoleStatus",
+ "MemoryStatus",
+]
+
+
+class BaseStatus(QW.QWidget):
+ """Base status widget.
+
+ Args:
+ delay (int | None): update interval (s). If None, widget will not be updated.
+ parent (QWidget): parent widget
+ """
+
+ def __init__(
+ self, delay: int | None = None, parent: QW.QWidget | None = None
+ ) -> None:
+ super().__init__(parent)
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ layout = QW.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+ self.icon = QW.QLabel()
+ self.label = QW.QLabel()
+ layout.addWidget(self.icon)
+ layout.addWidget(self.label)
+ if delay is not None:
+ self.timer = QC.QTimer()
+ self.timer.timeout.connect(self.update_status)
+ self.timer.start(delay * 1000)
+
+ def set_icon(self, icon: QG.QIcon | str | None) -> None:
+ """Set icon.
+
+ Args:
+ icon (QIcon | None): icon
+ """
+ size = self.label.sizeHint().height()
+ if isinstance(icon, str):
+ icon = get_icon(icon)
+ pixmap = QG.QPixmap() if icon is None else icon.pixmap(size, size)
+ self.icon.setPixmap(pixmap)
+
+ def update_status(self) -> None:
+ """Update status widget"""
+ raise NotImplementedError
+
+
+class ConsoleStatus(BaseStatus):
+ """Console status widget.
+
+ Shows a message if an error or warning has been logged to the console.
+ Shows a button to show the console, only if the console is hidden.
+
+ Args:
+ parent (QWidget): parent widget
+ """
+
+ SIG_SHOW_CONSOLE = QC.Signal()
+
+ def __init__(self, parent: QW.QWidget | None = None) -> None:
+ super().__init__(None, parent)
+ self.label.setText(_("Internal console"))
+ self.label.setToolTip(
+ _(
+ "Click to show the internal console.\n"
+ "The icon will turn red if an error or warning is logged."
+ )
+ )
+ self.label.setCursor(QG.QCursor(QC.Qt.PointingHandCursor))
+ self.label.mouseReleaseEvent = self.on_click
+ self.ok_icon = get_std_icon("MessageBoxInformation")
+ self.ko_icon = get_std_icon("MessageBoxWarning")
+ self.has_errors = False
+ self.update_status()
+
+ def on_click(self, event: QG.QMouseEvent) -> None:
+ """Handle mouse click event on label.
+
+ Args:
+ event: mouse event
+ """
+ if event.button() == QC.Qt.LeftButton:
+ self.SIG_SHOW_CONSOLE.emit()
+
+ def console_visibility_changed(self, visible: bool) -> None:
+ """Handle console visibility changed event.
+
+ Args:
+ visible (bool): console visibility
+ """
+ if visible:
+ # Hide this status widget when console is visible
+ self.hide()
+ else:
+ self.show()
+ self.update_status()
+
+ def exception_occurred(self) -> None:
+ """Handle exception occurred event"""
+ self.has_errors = True
+ self.update_status()
+
+ def update_status(self) -> None:
+ """Update status widget"""
+ if self.has_errors:
+ self.set_icon(self.ko_icon)
+ self.label.setStyleSheet("color: red")
+ self.label.setToolTip(
+ _(
+ "Click to show the internal console.\n"
+ "An error or warning has been logged."
+ )
+ )
+ else:
+ self.set_icon(self.ok_icon)
+ self.label.setStyleSheet("")
+ self.label.setToolTip(
+ _(
+ "Click to show the internal console.\n"
+ "No error or warning has been logged."
+ )
+ )
+
+
+class MemoryStatus(BaseStatus):
+ """Memory status widget.
+
+ Args:
+ threshold (int): available memory thresold (MB)
+ delay (int): update interval (s)
+ parent (QWidget): parent widget
+ """
+
+ SIG_MEMORY_ALARM = QC.Signal(bool)
+
+ def __init__(
+ self, threshold: int = 500, delay: int = 2, parent: QW.QWidget | None = None
+ ) -> None:
+ super().__init__(delay, parent)
+ self.demo_mode = False
+ self.ko_icon = get_std_icon("MessageBoxWarning")
+ self.__threshold = threshold * (1024**2)
+ self.label.setMinimumWidth(self.label.fontMetrics().width("000%"))
+ self.update_status()
+
+ def set_demo_mode(self, state: bool) -> None:
+ """Set demo mode state (used when taking screenshots).
+ The demo mode allows to take screenshots which always look the same.
+ (this will set memory usage to a constant value).
+ If demo mode is set to False, memory usage will be set to actual value.
+
+ Args:
+ state (bool): demo mode state
+ """
+ self.demo_mode = state
+ self.update_status()
+
+ def update_status(self) -> None:
+ """Update status widget"""
+ mem = psutil.virtual_memory()
+ memok = mem.available > self.__threshold
+ self.SIG_MEMORY_ALARM.emit(not memok)
+ txtlist = [
+ f"%s {mem.available // (1024**2)} MB" % _("Memory available:"),
+ f"%s {mem.used // (1024**2)} MB" % _("Memory used:"),
+ f"%s {self.__threshold // (1024**2)} MB" % _("Alarm threshold:"),
+ ]
+ txt = os.linesep.join(txtlist)
+ self.setToolTip(txt)
+ if DEBUG and not memok:
+ execenv.log(self, txt)
+ self.label.setStyleSheet("" if memok else "color: red")
+ self.set_icon("libre-tech-ram.svg" if memok else self.ko_icon)
+ mem_percent = 65 if self.demo_mode else int(mem.percent)
+ self.label.setText(_("Memory:") + f" {mem_percent}%")
diff --git a/sigimax/widgets/warningerror.py b/sigimax/widgets/warningerror.py
new file mode 100644
index 0000000..2132a44
--- /dev/null
+++ b/sigimax/widgets/warningerror.py
@@ -0,0 +1,234 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+Module providing a warning/error message box
+
+.. autoclass:: WarningErrorMessageBox
+ :members:
+.. autofunction:: show_warning_error
+"""
+
+import os.path as osp
+import re
+import subprocess
+import traceback
+
+from guidata.config import CONF
+from guidata.configtools import get_font, get_icon
+from guidata.qthelpers import exec_dialog, get_std_icon
+from guidata.widgets.console.shell import PythonShellWidget
+from qtpy import QtCore as QC
+from qtpy import QtWidgets as QW
+
+from sigimax.config import _, get_conf, get_mod_source_dir
+
+__all__ = [
+ "WarningErrorMessageBox",
+ "go_to_error",
+ "show_warning_error",
+]
+
+
+def go_to_error(text: str) -> None:
+ """Go to error: open file with external editor, and go to line number
+
+ Args:
+ text (str): Error text
+ """
+ pattern = r'File "(.+)", line (\d+),'
+ match = re.search(pattern, text)
+ if match:
+ path = match.group(1)
+ line_number = match.group(2)
+ mod_src_dir = get_mod_source_dir()
+ if not osp.isfile(path) and mod_src_dir is not None:
+ otherpath = osp.join(mod_src_dir, path)
+ if not osp.isfile(otherpath):
+ # TODO: [P3] For frozen app, go to error is implemented only when the
+ # source code is available locally (development mode).
+ # How about using a web browser to open the source code on github?
+ return
+ path = otherpath
+ if not osp.isfile(path):
+ return # File not found (unhandled case)
+ fdict = {"path": path, "line_number": line_number}
+ args = get_conf().external_editor_args.get().format(**fdict).split(" ")
+ editor_path = get_conf().external_editor_path.get()
+ subprocess.run([editor_path] + args, shell=True, check=False)
+
+
+def insert_spaces(text: str, nbchars: int) -> str:
+ """
+ Inserts spaces regularly in a string, every nbchars characters, after certain
+ characters (",", ";", "-", "+", "*", ")"), and keeps searching until detecting
+ one of those characters.
+
+ Args:
+ text (str): The input string.
+ nbchars (int): The number of characters after which a space should be inserted.
+
+ Returns:
+ str: The modified string with spaces inserted.
+ """
+ special_chars = (",", ";", "-", "+", "*", ")", "_")
+ new_text = ""
+ index = 0
+ while index < len(text):
+ if (
+ index + nbchars < len(text)
+ and text[index + nbchars] not in special_chars
+ and not any(c in special_chars for c in text[index : index + nbchars + 1])
+ ):
+ new_text += text[index : index + nbchars] # Append characters
+ index += nbchars
+ else:
+ new_text += text[index : index + nbchars] + " " # Insert space
+ index += nbchars
+ return new_text
+
+
+class WarningErrorMessageBox(QW.QDialog):
+ """Warning/Error message box
+
+ Args:
+ parent (QW.QWidget): parent widget
+ category (str): message category ("error" or "warning")
+ context (str | None): context. Defaults to None.
+ message (str | None): message. Defaults to None.
+ tip (str | None): tip. Defaults to None.
+ """
+
+ def __init__(
+ self,
+ parent: QW.QWidget,
+ category: str,
+ context: str = None,
+ message: str = None,
+ tip: str = None,
+ ) -> None:
+ super().__init__(parent)
+ assert category in ("error", "warning")
+ self.setWindowTitle(parent.window().objectName())
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ self.shell = PythonShellWidget(self, read_only=True)
+ self.shell.go_to_error.connect(go_to_error)
+ font = get_font(CONF, "console")
+ font.setPointSize(9)
+ self.shell.set_font(font)
+ message = traceback.format_exc() if message is None else message
+ self.shell.insert_text(message, at_end=True, error=True)
+
+ bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok)
+ bbox.accepted.connect(self.accept)
+ if category == "warning":
+ bbox.addButton(QW.QDialogButtonBox.Ignore).clicked.connect(self.ignore)
+
+ layout = QW.QVBoxLayout()
+
+ if category == "error":
+ width, height = 725, 400
+ icon = "MessageBoxCritical"
+ tb_title = _("Error message")
+ tb_text = _("The following traceback may help to understand the problem:")
+ else:
+ width, height = 725, 200
+ icon = "MessageBoxWarning"
+ tb_title = _("Warning message")
+ tb_text = _("Please take into account the following warning message:")
+
+ if context is not None:
+ context = insert_spaces(context, 80)
+ msgprefix = _("An error has occured during the following context:")
+ text = "
".join([msgprefix, f"{context}"])
+ ct_groupbox = QW.QGroupBox(_("Context"), self)
+ ct_layout = QW.QHBoxLayout()
+ ct_image_layout = QW.QVBoxLayout()
+ ct_image = QW.QLabel()
+ ct_image.setPixmap(get_std_icon(icon).pixmap(24, 24))
+ ct_image.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed)
+ ct_image_layout.addWidget(ct_image)
+ ct_image_layout.addStretch()
+ ct_layout.addLayout(ct_image_layout)
+ ct_label = QW.QLabel(text)
+ ct_label.setWordWrap(True)
+ ct_label.setAlignment(QC.Qt.AlignLeft | QC.Qt.AlignTop)
+ ct_layout.addWidget(ct_label)
+ ct_groupbox.setLayout(ct_layout)
+ ct_groupbox.setSizePolicy(
+ QW.QSizePolicy.MinimumExpanding, QW.QSizePolicy.Fixed
+ )
+ layout.addWidget(ct_groupbox)
+
+ tb_groupbox = QW.QGroupBox(tb_title, self)
+ tb_layout = QW.QVBoxLayout()
+ tb_layout.addWidget(QW.QLabel(tb_text))
+ tb_layout.addWidget(self.shell)
+ tb_groupbox.setLayout(tb_layout)
+ layout.addWidget(tb_groupbox)
+
+ if tip is not None:
+ tip_groupbox = QW.QGroupBox(_("Tip"), self)
+ tip_layout = QW.QHBoxLayout()
+ tip_image_layout = QW.QVBoxLayout()
+ tip_image = QW.QLabel()
+ tip_image.setPixmap(get_std_icon("MessageBoxInformation").pixmap(24, 24))
+ tip_image.setSizePolicy(QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed)
+ tip_image_layout.addWidget(tip_image)
+ tip_image_layout.addStretch()
+ tip_layout.addLayout(tip_image_layout)
+ tip_label = QW.QLabel(tip)
+ tip_label.setWordWrap(True)
+ tip_label.setAlignment(QC.Qt.AlignLeft | QC.Qt.AlignTop)
+ tip_layout.addWidget(tip_label)
+ tip_groupbox.setLayout(tip_layout)
+ tip_groupbox.setSizePolicy(
+ QW.QSizePolicy.MinimumExpanding, QW.QSizePolicy.Fixed
+ )
+ layout.addWidget(tip_groupbox)
+
+ layout.addSpacing(10)
+ if category == "warning":
+ layout.addWidget(
+ QW.QLabel(
+ _(
+ "Please click on the 'Ignore' button to "
+ "ignore this warning next time."
+ )
+ )
+ )
+ layout.addSpacing(10)
+
+ layout.addWidget(bbox)
+
+ self.setLayout(layout)
+ self.resize(width, height)
+
+ bbox.button(QW.QDialogButtonBox.Ok).setFocus()
+
+ def ignore(self):
+ """Ignore warning next time"""
+ get_conf().ignore_warnings.set(True)
+ self.accept()
+
+
+def show_warning_error(
+ parent: QW.QWidget,
+ category: str,
+ context: str = None,
+ message: str = None,
+ tip: str = None,
+) -> None:
+ """Show error message
+
+ Args:
+ parent (QW.QWidget): parent widget
+ category (str): message category ("error" or "warning")
+ context (str | None): context. Defaults to None.
+ message (str | None): message. Defaults to None.
+ tip (str | None): tip. Defaults to None.
+ """
+ if category == "warning" and get_conf().ignore_warnings.get():
+ return
+ dlg = WarningErrorMessageBox(parent, category, context, message, tip)
+ exec_dialog(dlg)
diff --git a/sigimax/widgets/wizard.py b/sigimax/widgets/wizard.py
new file mode 100644
index 0000000..426e626
--- /dev/null
+++ b/sigimax/widgets/wizard.py
@@ -0,0 +1,319 @@
+# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
+
+"""
+SigimaX Wizard Widget
+---------------------
+
+The SigimaX Wizard is a widget that guides the user through a series of steps
+to complete a task. It is implemented as a series of pages, each of which is
+a separate widget.
+
+The `Wizard` class is the main widget that contains the pages. The `WizardPage`
+class is the base class for the pages.
+
+This module is strongly inspired from Qt's `QWizard` and `QWizardPage` classes.
+
+.. note::
+
+ The only motivation for reimplementing the wizard widget is to
+ support complete styling with `QPalette` and `QStyle` (e.g. `guidata`'s
+ dark mode is not supported on Windows).
+
+.. autoclass:: Wizard
+ :members:
+.. autoclass:: WizardPage
+ :members:
+"""
+
+from __future__ import annotations
+
+from guidata.configtools import get_icon
+from qtpy import QtCore as QC
+from qtpy import QtGui as QG
+from qtpy import QtWidgets as QW
+from qtpy.compat import getopenfilename
+from qtpy.QtWidgets import QWidget
+
+from sigimax.config import _, get_conf
+
+__all__ = [
+ "Wizard",
+ "WizardPage",
+]
+
+
+class WizardPage(QW.QWidget):
+ """Wizard page base class
+
+ We create our own wizard page class instead of using QWizardPage because
+ the latter does not support complete styling with `QPalette` and `QStyle`
+ (e.g. `guidata`'s dark mode is not supported on Windows).
+
+ This class reimplements the `QWizardPage` features.
+
+ """
+
+ SIG_INITIALIZE_PAGE = QC.Signal()
+ SIG_VALID_STATE_CHANGED = QC.Signal()
+
+ def __init__(self, parent: QW.QWidget | None = None) -> None:
+ super().__init__(parent)
+ if parent is None:
+ self.setWindowIcon(get_icon(get_conf().app_logo_path.get()))
+ self.__is_valid: bool = True
+ self.wizard: Wizard | None = None
+ self._main_layout = QW.QVBoxLayout()
+ self._user_layout = QW.QVBoxLayout()
+ self._title_label = QW.QLabel("")
+ font = self._title_label.font()
+ font.setPointSize(font.pointSize() + 4)
+ font.setBold(True)
+ self._title_label.setFont(font)
+ self._title_label.setStyleSheet("color: #1E90FF")
+ horiz_line = QW.QFrame()
+ horiz_line.setFrameShape(QW.QFrame.HLine)
+ horiz_line.setFrameShadow(QW.QFrame.Sunken)
+ self._subtitle_label = QW.QLabel("")
+ self._main_layout.addWidget(self._title_label)
+ self._main_layout.addWidget(self._subtitle_label)
+ self._main_layout.addWidget(horiz_line)
+ self._main_layout.addLayout(self._user_layout)
+ self.setLayout(self._main_layout)
+
+ def set_wizard(self, wizard: Wizard) -> None:
+ """Set the wizard"""
+ self.wizard = wizard
+
+ def get_wizard(self) -> Wizard:
+ """Return the wizard"""
+ return self.wizard
+
+ def set_title(self, title: str) -> None:
+ """Set the title of the page"""
+ self._title_label.setText(title)
+
+ def set_subtitle(self, subtitle: str) -> None:
+ """Set the subtitle of the page"""
+ self._subtitle_label.setText(subtitle)
+
+ def set_valid(self, is_valid: bool) -> None:
+ """Set the page as valid"""
+ self.__is_valid = is_valid
+ self.SIG_VALID_STATE_CHANGED.emit()
+
+ def is_valid(self) -> bool:
+ """Return whether the page is valid"""
+ return self.__is_valid
+
+ def add_to_layout(self, layout: QW.QLayout | QW.QWidget) -> None:
+ """Add a layout to the user layout"""
+ if isinstance(layout, QW.QWidget):
+ self._user_layout.addWidget(layout)
+ else:
+ self._user_layout.addLayout(layout)
+
+ def add_stretch(self) -> None:
+ """Add a stretch to the user layout"""
+ self._user_layout.addStretch()
+
+ def initialize_page(self) -> None:
+ """Initialize the page"""
+ self.SIG_INITIALIZE_PAGE.emit()
+
+ def validate_page(self) -> bool:
+ """Validate the page"""
+ return self.is_valid()
+
+
+class Wizard(QW.QDialog):
+ """Wizard base class"""
+
+ def __init__(self, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+
+ _main_layout = QW.QVBoxLayout()
+ self.setLayout(_main_layout)
+
+ self._pages_widget = QW.QStackedWidget()
+ _main_layout.addWidget(self._pages_widget)
+
+ btn_layout = QW.QHBoxLayout()
+ self._back_btn = QW.QPushButton(_("Back"))
+ self._back_btn.clicked.connect(self.go_to_previous_page)
+ self._next_btn = QW.QPushButton(_("Next"))
+ self._next_btn.clicked.connect(self.go_to_next_page)
+ self._finish_btn = QW.QPushButton(_("Finish"))
+ self._finish_btn.clicked.connect(self.accept)
+ self._cancel_btn = QW.QPushButton(_("Cancel"))
+ self._cancel_btn.clicked.connect(self.reject)
+ btn_layout.addWidget(self._back_btn)
+ btn_layout.addWidget(self._next_btn)
+ btn_layout.addWidget(self._finish_btn)
+ btn_layout.addWidget(self._cancel_btn)
+ _main_layout.addLayout(btn_layout)
+
+ self.setSizePolicy(
+ QW.QSizePolicy(QW.QSizePolicy.Minimum, QW.QSizePolicy.Minimum)
+ )
+
+ def cleanup(self) -> None:
+ """Release page resources before the dialog is destroyed.
+
+ Pages that embed native-heavy widgets (e.g. a PlotPy plot) may expose a
+ ``cleanup`` method to tear those resources down deterministically. This
+ avoids a Qt/PlotPy native teardown race (access violation) when several
+ wizards are created and destroyed in sequence.
+ """
+ for index in range(self._pages_widget.count()):
+ page = self._pages_widget.widget(index)
+ page_cleanup = getattr(page, "cleanup", None)
+ if callable(page_cleanup):
+ page_cleanup()
+
+ def closeEvent(self, event: QG.QCloseEvent) -> None: # pylint: disable=invalid-name
+ """Release page resources when the dialog is closed"""
+ self.cleanup()
+ super().closeEvent(event)
+
+ def add_page(self, page: WizardPage, last_page: bool = False) -> None:
+ """Add a page to the wizard"""
+ page.set_wizard(self)
+ page.SIG_INITIALIZE_PAGE.connect(self.__update_button_states)
+ page.SIG_VALID_STATE_CHANGED.connect(self.__update_button_states)
+ self._pages_widget.addWidget(page)
+ if last_page:
+ self._pages_widget.widget(0).initialize_page()
+
+ def __update_button_states(self, index: int | None = None) -> None:
+ """Update button states"""
+ if index is None:
+ index = self._pages_widget.currentIndex()
+ self._back_btn.setEnabled(index > 0)
+ not_last_page = index < self._pages_widget.count() - 1
+ page_valid = self._pages_widget.currentWidget().is_valid()
+ self._next_btn.setEnabled(not_last_page and page_valid)
+ is_last_page = index == self._pages_widget.count() - 1
+ self._finish_btn.setEnabled(is_last_page and page_valid)
+
+ def go_to_previous_page(self) -> None:
+ """Go to the previous page"""
+ self._pages_widget.setCurrentIndex(self._pages_widget.currentIndex() - 1)
+ self.__update_button_states()
+
+ def go_to_next_page(self) -> None:
+ """Go to the next page"""
+ if self.validate_page():
+ self._pages_widget.setCurrentIndex(self._pages_widget.currentIndex() + 1)
+ self.initialize_page()
+
+ def initialize_page(self) -> None:
+ """Initialize the page"""
+ self._pages_widget.currentWidget().initialize_page()
+
+ def validate_page(self) -> bool:
+ """Validate the page"""
+ return self._pages_widget.currentWidget().validate_page()
+
+ def accept(self) -> None:
+ """Accept the wizard"""
+ if self.validate_page():
+ super().accept()
+
+
+class ExamplePage1(WizardPage):
+ """Example wizard page 1"""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.set_title(_("Welcome to the Example Wizard"))
+ self.set_subtitle(
+ _("This wizard will guide you through the process of importing data.")
+ )
+
+ def initialize_page(self) -> None:
+ """Initialize the page"""
+ print("ExamplePage1 initialized")
+ super().initialize_page()
+
+ def validate_page(self) -> bool:
+ """Validate the page"""
+ print("ExamplePage1 validated")
+ return super().validate_page()
+
+
+class ExamplePage2(WizardPage):
+ """Example wizard page 2"""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.set_title(_("Select the Source of the Data"))
+ self.set_subtitle(
+ _("Select the source of the data to be imported (clipboard or file).")
+ )
+ self._clipboard_rb = QW.QRadioButton(_("Clipboard"))
+ self._file_rb = QW.QRadioButton(_("File"))
+ self._file_rb.toggled.connect(self.file_rb_toggled)
+ self._file_le = QW.QLineEdit()
+ self._file_btn = QW.QPushButton(_("Browse..."))
+ self._file_btn.clicked.connect(self.browse_file)
+ self.add_to_layout(self._clipboard_rb)
+ self.add_to_layout(self._file_rb)
+ self.add_to_layout(self._file_le)
+ self.add_to_layout(self._file_btn)
+
+ def initialize_page(self) -> None:
+ """Initialize the page"""
+ print("ExamplePage2 initialized")
+ super().initialize_page()
+
+ def file_rb_toggled(self, checked: bool) -> None:
+ """File radio button toggled"""
+ self._file_le.setEnabled(checked)
+ self._file_btn.setEnabled(checked)
+
+ def browse_file(self) -> None:
+ """Browse file"""
+ file_name, _filt = getopenfilename(
+ self,
+ _("Select the File to Import"),
+ "",
+ _("CSV Files (*.csv);;Text Files (*.txt);;All Files (*)"),
+ )
+ if file_name:
+ self._file_le.setText(file_name)
+
+ def validate_page(self) -> bool:
+ """Validate the page"""
+ if self._file_rb.isChecked() and not self._file_le.text():
+ QW.QMessageBox.critical(
+ self,
+ _("Error"),
+ _("Please select the file to import."),
+ QW.QMessageBox.Ok,
+ )
+ return False
+ return True
+
+
+class ExampleWizard(Wizard):
+ """Example wizard widget"""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.setWindowTitle(_("Example Wizard"))
+ self.add_page(ExamplePage1())
+ self.add_page(ExamplePage2(), last_page=True)
+
+
+def test_example_wizard():
+ """Test the import wizard"""
+ # pylint: disable=import-outside-toplevel
+ from guidata.qthelpers import qt_app_context
+
+ with qt_app_context():
+ wizard = ExampleWizard()
+ wizard.exec()
+
+
+if __name__ == "__main__":
+ test_example_wizard()