diff --git a/.coveragerc b/.coveragerc index 91288ea..78644a3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] -#--- Ignore this warning because the process isolation feature of DataLab +#--- Ignore this warning because the process isolation feature #--- causes coverage to report 0% coverage when no computation is performed #--- in the isolated process during the session. disable_warnings = no-data-collected diff --git a/.env.template b/.env.template index 60f01f7..b06759e 100644 --- a/.env.template +++ b/.env.template @@ -1 +1,10 @@ -PYTHONPATH=. \ No newline at end of file +# Python interpreter (explicit path, e.g. WinPython). Auto-detected if empty. +PYTHON= +# WinPython base directory (legacy, prefer PYTHON instead) +# WINPYDIRBASE= +# Virtual environment directory (e.g. .venv39). Auto-discovered if empty. +VENV_DIR= +# Python path for development (sibling packages) +PYTHONPATH=. +# Locale (e.g. fr) +LANG= \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9c3f2f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Installation information** +Describe how you installed SigimaX (e.g., via pip, conda, or from source) and provide the version of SigimaX you are using. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/doc_request.md b/.github/ISSUE_TEMPLATE/doc_request.md new file mode 100644 index 0000000..3b67d65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/doc_request.md @@ -0,0 +1,20 @@ +--- +name: Documentation request +about: Ask for documentation about a specific topic +title: '' +labels: documentation +assignees: '' + +--- + +**Is your documentation request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the topic you would like to be covered** +A clear and concise description of what you want to be documented. + +**Describe the nature of the documentation you would like to see** +Tutorial, reference, etc. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..11fc491 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/tutorial_request.md b/.github/ISSUE_TEMPLATE/tutorial_request.md new file mode 100644 index 0000000..ad1271e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tutorial_request.md @@ -0,0 +1,23 @@ +--- +name: Tutorial request +about: Ask for a tutorial on a specific topic +title: '' +labels: documentation +assignees: '' + +--- + +**Describe the context and technical field of the tutorial you would like to see** +A clear and concise description of your technical field, and of the specific application. + +**Describe the topic you would like to be covered** +A clear and concise description of what you want to be documented. + +**Describe the features you would like to see in the tutorial** +A clear and concise description of the features you would like to see in the tutorial. + +**Additional context** +Add any other context or screenshots about the feature request here. + +_Please attach an example data set, if possible. +And please confirm that you are willing to share this data set under the terms of DataLab's license._ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..9a44b89 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,394 @@ +# SigimaX AI Coding Agent Instructions + +## Project Overview + +**SigimaX** is a reusable GUI application framework extracted from DataLab. It provides the generic "application skeleton" that any scientific computing Qt application can build upon by subclassing its main window and configuration system. + +### Position in the Stack + +``` +End-user apps (DataLab, custom scientific apps) + ↓ subclass / configure + SigimaX ← THIS PROJECT (framework layer) + ↓ depends on + Sigima (computation) + PlotPy + guidata + PythonQwt + ↓ + NumPy / SciPy / Qt +``` + +### Technology Stack + +- **Python**: 3.9+ (`from __future__ import annotations`) +- **Core**: guidata (≥3.13.4), PlotPy (≥2.8.2), Sigima (≥1.1.0), psutil (≥5.7) +- **GUI**: Qt via QtPy (PyQt5/PyQt6/PySide6) +- **Testing**: pytest +- **Linting**: Ruff (preferred), Pylint + +### Architecture + +``` +sigimax/ +├── app.py # Application launcher (create / run) +├── config.py # Configuration system (SigimaXOptions, CONF singleton) +├── env.py # ExecEnv runtime environment (verbosity, unattended) +├── mainwindow.py # SGMXMainWindow (generic main window) +├── widgets/ # Reusable Qt widgets +│ ├── plotdock.py # DockablePlotWidget +│ ├── splashscreen.py # Configurable splash screen +│ ├── h5browser.py # HDF5 file browser +│ ├── logviewer.py # Log viewer dialog +│ ├── status.py # Status bar widgets (memory, console) +│ ├── warningerror.py # Warning/error message box +│ ├── wizard.py # Multi-page wizard dialog +│ ├── fitdialog.py # Curve fitting dialogs +│ ├── filedialog.py # File dialog with multi-selection +│ ├── fileviewer.py # Read-only file viewer +│ ├── imagebackground.py # Image background selection +│ ├── signalbaseline.py # Signal baseline selection +│ ├── signalcursor.py # Signal cursor selection +│ ├── signaldeltax.py # Signal delta-X measurement +│ └── signalpeak.py # Signal peak detection +├── h5/ # HDF5 I/O (read/write/import) +├── adapters_plotpy/ # Converters between PlotPy/guidata and Sigima objects +├── utils/ # Qt helpers, config dir resolution +├── data/ # Icons, resources +├── locale/ # Translations (EN, FR) +└── tests/ # pytest suite +``` + +## Development Workflows + +### Running Commands + +**ALWAYS use `scripts/run_with_env.py`** to load `.env` before running Python commands: + +```powershell +# ✅ CORRECT +python scripts/run_with_env.py python -m pytest + +# ❌ WRONG - Misses local PYTHONPATH +python -m pytest +``` + +### Testing + +```powershell +# Run all tests +python scripts/run_with_env.py python -m pytest --ff + +# Run specific test +python scripts/run_with_env.py python -m pytest sigimax/tests/derivated_app_test.py + +# Show Qt windows during tests (default is offscreen) +python scripts/run_with_env.py python -m pytest --show-windows +``` + +**Pytest Configuration** (`conftest.py`): +- `execenv.unattended = True` (no GUI interaction by default) +- `set_validation_mode(ValidationMode.STRICT)` for tests +- `QT_QPA_PLATFORM=offscreen` unless `--show-windows` is passed +- Custom marker: `@pytest.mark.validation` + +### Linting and Formatting + +```powershell +# Ruff (preferred) +python scripts/run_with_env.py python -m ruff format +python scripts/run_with_env.py python -m ruff check --fix + +# Pylint +python scripts/run_with_env.py python -m pylint sigimax \ + --disable=duplicate-code,fixme,too-many-arguments, \ + too-many-branches,too-many-instance-attributes,too-many-lines, \ + too-many-locals,too-many-public-methods,too-many-statements +``` + +### Translations + +```powershell +# Scan and update .po files +python scripts/run_with_env.py python -m guidata.utils.translations scan \ + --name sigimax --directory . --copyright-holder "DataLab Platform Developers" \ + --languages fr + +# Compile .mo files +python scripts/run_with_env.py python -m guidata.utils.translations compile \ + --name sigimax --directory . +``` + +## Core Patterns + +### 1. The Derivation Pattern (Subclassing) + +SigimaX is designed around **subclassing**. Derived applications follow three steps: + +**Step 1 — Subclass `SigimaXOptions`** for app-specific configuration: + +```python +from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField + +class MyAppOptions(SigimaXOptions): + ENV_VAR = "MYAPP_OPTIONS_JSON" + APP_NAME = "MyApp" + + def __init__(self): + super().__init__() + self.app_name.set("MyApp") + self.greeting = TypedOptionField( + self, "greeting", default="Hello!", + expected_type=str, description="Startup message", + ) + self.unit_system = EnumOptionField( + self, "unit_system", default="metric", + choices=["metric", "imperial"], + description="Default units", + ) +``` + +**Step 2 — Subclass `SGMXMainWindow`** for custom UI: + +```python +from sigimax.mainwindow import SGMXMainWindow +from sigimax.config import CONF as Conf + +class MyAppMainWindow(SGMXMainWindow): + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("MyApp") + super().__init__(console=console, hide_on_close=hide_on_close) + self._add_custom_menus() + self._add_custom_docks() +``` + +**Step 3 — Launch with `sigimax.app.run()`**: + +```python +from sigimax.app import run +from sigimax.widgets.splashscreen import SplashScreenConfig + +run( + window_class=MyAppMainWindow, + splash_config=SplashScreenConfig( + image_path="myapp/data/splash.png", + app_name="MyApp", + app_version="1.0.0", + ), +) +``` + +### 2. Configuration System + +The configuration system follows Sigima's `OptionField` pattern extended for GUI apps: + +```python +from sigimax.config import CONF as Conf + +# Get/set options +colormap = Conf.ima_def_colormap.get() +Conf.ima_def_colormap.set("gray") + +# Context manager for temporary overrides +with Conf.fft_shift_enabled.context(False): + # FFT shift disabled in this block + ... + +# JSON persistence +Conf.save() # Save to config file +Conf.load() # Load from config file +``` + +**Custom option field types**: +- `TypedOptionField` — type-checked (int, str, bool, float) +- `EnumOptionField` — constrained to a set of choices +- `TupleOptionField` — fixed-length tuples +- `ImageIOOptionField` — image I/O settings (inherited from Sigima) + +### 3. Widgets Package + +Common widgets are re-exported from `sigimax.widgets` for convenience: + +```python +# Tier 1 — direct import from package +from sigimax.widgets import H5Browser, Wizard, LogViewerWindow, SplashScreenConfig + +# Tier 2 — specialized dialogs via submodule +from sigimax.widgets.fitdialog import gaussian_fit +from sigimax.widgets.signalpeak import SignalPeakDetectionDialog +``` + +### 4. DockablePlotWidget + +Embeds PlotPy plots in dock widgets: + +```python +from sigimax.widgets.plotdock import DockablePlotWidget + +dock = DockablePlotWidget(self, plot_type=PlotType.CURVE, title="My Plot") +self.addDockWidget(Qt.RightDockWidgetArea, dock) +``` + +### 5. HDF5 Workspace + +The main window provides built-in HDF5 workspace management: +- `open_h5_files()` — open HDF5 files +- `save_to_h5_file()` — save workspace +- `browse_h5_files()` — browse HDF5 files with `H5BrowserDialog` + +### 6. Application Launcher + +```python +from sigimax.app import create, run + +# create() — instantiate window with splash, return it (for embedding) +window = create(window_class=MyWindow, splash=True, console=True) + +# run() — create() + enter Qt event loop (for standalone apps) +run(window_class=MyWindow, splash_config=config) +``` + +## What SigimaX Provides vs What Stays in DataLab + +| **In SigimaX** | **Stays in DataLab** | +|---|---| +| Configuration system (`config.py`) | Signal/Image panels, processors | +| Generic main window (`mainwindow.py`) | Action handler, plugin system | +| Dockable plot widgets (`widgets/plotdock.py`) | Remote control (XML-RPC, Web API) | +| HDF5 I/O + browser (`h5/`, `widgets/h5browser.py`) | Macro editor, new-object dialogs | +| Scientific dialogs (fit, baseline, peak, cursor…) | DataLab-specific UI and processing | +| Log viewer, status bar, splash screen, wizard | Object model, plot handler | +| PlotPy adapters (`adapters_plotpy/`) | Processor registration pattern | +| Environment/exec utilities (`env.py`) | Tour/tutorial features | + +## Coding Conventions + +### Type Annotations + +```python +from __future__ import annotations +``` + +Always use `from __future__ import annotations` for forward references. + +### Qt Imports + +Use QtPy for Qt binding abstraction: + +```python +from qtpy.QtCore import Qt, QSize +from qtpy.QtGui import QPen, QBrush, QColor +from qtpy.QtWidgets import QWidget +``` + +### Imports + +**Order**: Standard library → Third-party → SigimaX + +```python +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import numpy as np +from guidata.qthelpers import create_action + +from sigimax.config import CONF as Conf +from sigimax.config import _ +from sigimax.mainwindow import SGMXMainWindow + +if TYPE_CHECKING: + from sigima.objects import SignalObj +``` + +### Module Exports + +**Always define `__all__`** in every module: + +```python +__all__ = [ + "MyClass", + "my_function", +] +``` + +### Docstrings + +Google-style with Args/Returns: + +```python +def my_function(x: np.ndarray, param: int) -> np.ndarray: + """One-line summary. + + Longer description if needed. + + Args: + x: Input array description + param: Parameter description, with long description that + continues on next line. + + Returns: + Output array description + """ +``` + +For continued lines in enumerations (args, returns), indent subsequent lines by 1 space. + +### Internationalization + +Wrap UI strings with `_()`: + +```python +from sigimax.config import _ + +menu_title = _("Processing") +action_text = _("Open HDF5 files") +``` + +### Naming + +- **Functions**: `snake_case` (e.g., `get_log_filenames`) +- **Classes**: `PascalCase` (e.g., `SGMXMainWindow`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `MOD_NAME`, `DEBUG`) +- **Private methods**: `_snake_case` or `__snake_case` + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `sigimax/__init__.py` | Package metadata (`__version__`, URLs) | +| `sigimax/app.py` | Application launcher (`create()`, `run()`) | +| `sigimax/config.py` | Configuration system (`SigimaXOptions`, `CONF`) | +| `sigimax/env.py` | Runtime environment (`ExecEnv`, `execenv` singleton) | +| `sigimax/mainwindow.py` | `SGMXMainWindow` — generic main window | +| `sigimax/widgets/plotdock.py` | `DockablePlotWidget` — embeddable plot docks | +| `sigimax/widgets/__init__.py` | Convenience re-exports of common widgets | +| `sigimax/widgets/splashscreen.py` | `SplashScreenConfig`, `SigimaXSplashScreen` | +| `sigimax/widgets/h5browser.py` | HDF5 browser widget and dialog | +| `sigimax/h5/__init__.py` | HDF5 I/O handler | +| `sigimax/adapters_plotpy/__init__.py` | PlotPy/Sigima object converters | +| `sigimax/tests/derivated_app_test.py` | Reference example of derived application | +| `scripts/run_with_env.py` | Environment loader (loads `.env`) | +| `.env` | Local PYTHONPATH for development | + +## VS Code Tasks + +`.vscode/tasks.json` provides shortcuts: + +- **🧽 Ruff Formatter**: Format code +- **🔦 Ruff Linter**: Lint with auto-fix +- **🧽🔦 Ruff**: Format + lint (sequential) +- **🔦 Pylint**: Pylint checks +- **🚀 Pytest**: Run tests (`--ff` flag) +- **📚 Compile translations**: Build .mo files +- **🔎 Scan translations**: Update .po files + +## Related Projects + +- **Sigima**: Headless computation library (sibling, upstream) +- **guidata**: Dataset/parameter framework (upstream) +- **PlotPy**: Interactive plotting (upstream) +- **PythonQwt**: Low-level Qt plotting (upstream) +- **DataLab**: Primary downstream application using SigimaX + +--- + +**Remember**: Always use `scripts/run_with_env.py` for Python commands, wrap UI strings with `_()`, define `__all__` in every module, and follow the subclassing pattern for derived applications. diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml new file mode 100644 index 0000000..081f579 --- /dev/null +++ b/.github/workflows/build_deploy.yml @@ -0,0 +1,41 @@ +name: Build and upload to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + test_pyqt5: + uses: ./.github/workflows/test_pyqt5.yml + + test_pyqt6: + uses: ./.github/workflows/test_pyqt6.yml + + deploy: + needs: [test_pyqt5, test_pyqt6] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build guidata babel + - name: Compile translations + run: | + python -m guidata.utils.translations compile --name sigimax --directory . + - name: Build package + run: python -m build + #- name: Publish package + # uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + # with: + # user: __token__ + # password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test_pyqt5.yml b/.github/workflows/test_pyqt5.yml new file mode 100644 index 0000000..6460706 --- /dev/null +++ b/.github/workflows/test_pyqt5.yml @@ -0,0 +1,154 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +# Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions + +name: Install and Test on Ubuntu (latest) with PyQt5 + +on: + push: + branches: [ "main", "develop", "release" ] + pull_request: + branches: [ "main", "develop", "release" ] + workflow_dispatch: + inputs: + job_to_run: + description: 'Which job to run' + required: true + type: choice + options: + - 'all' + - 'build' + - 'build_latest' + default: 'all' + schedule: + # Only the "build_latest" job runs on schedule (see execution conditions below) + - cron: "0 5 * * 1" + +jobs: + build: + if: ${{ (github.event_name == 'push' || github.event_name == 'pull_request') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build')) }} + + env: + DISPLAY: ':99.0' + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + python -m pip install --upgrade pip + python -m pip install ruff pytest + pip install PyQt5 + if [ "${{ github.ref_name }}" = "develop" ]; then + # Clone and install development versions of key dependencies with editable install + cd .. + git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git + git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git + git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git + git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + cd SigimaX + pip install -e ../guidata + pip install -e ../PythonQwt + pip install -e ../plotpy + pip install -e ../sigima + # Install tomli for TOML parsing (safe if already present) + pip install tomli + # Extract dependencies and save to file, then install + python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" + pip install -r deps.txt + # Install SigimaX without dependencies + pip install --no-deps . + elif [ "${{ github.ref_name }}" = "release" ]; then + # Clone dependencies from release branches (with fallback to main/master) + cd .. + # Try cloning PythonQwt from main or master + git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/PythonQwt.git + # Try cloning guidata from release, fallback to main or master + git clone --depth 1 --branch release https://github.com/PlotPyStack/guidata.git || git clone --depth 1 https://github.com/PlotPyStack/guidata.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/guidata.git + # Try cloning plotpy from release, fallback to main or master + git clone --depth 1 --branch release https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/plotpy.git + # Try cloning sigima from release, fallback to main + git clone --depth 1 --branch release https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git + cd SigimaX + pip install -e ../guidata + pip install -e ../PythonQwt + pip install -e ../plotpy + pip install -e ../sigima + # Install tomli for TOML parsing (safe if already present) + pip install tomli + # Extract dependencies and save to file, then install + python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" + pip install -r deps.txt + # Install SigimaX without dependencies + pip install --no-deps . + else + # Install from PyPI normally for main branch + pip install . + fi + - name: Lint with Ruff + run: ruff check --output-format=github sigimax + - name: Test with pytest + run: pytest -v --tb=long + + build_latest: + if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build_latest')) }} + env: + DISPLAY: ':99.0' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install dependencies (latest) + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y \ + libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ + libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils + + /sbin/start-stop-daemon --start --quiet \ + --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background \ + --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + + python -m pip install --upgrade pip + python -m pip install ruff pytest + python -m pip install PyQt5 + + # Clone and install Sigima from the same branch as SigimaX + cd .. + git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git + cd SigimaX + + # Install SigimaX itself, but do NOT install its pinned deps + python -m pip install -e . --no-deps + + # Install Sigima from local clone + python -m pip install -e ../sigima + + # Extract dependency names from pyproject.toml (excluding Sigima) and install latest versions + python -m pip install -U --upgrade-strategy eager $(python -c "import tomllib, re; print(' '.join(re.sub(r'[\[\]<>=!~,.\s].*$', '', d).strip() for d in tomllib.loads(open('pyproject.toml', 'rb').read().decode())['project']['dependencies'] if 'Sigima' not in d))") + + - name: Lint with Ruff (latest) + run: ruff check --output-format=github sigimax + + - name: Test with pytest (latest) + run: pytest -v --tb=long \ No newline at end of file diff --git a/.github/workflows/test_pyqt6.yml b/.github/workflows/test_pyqt6.yml new file mode 100644 index 0000000..12369a4 --- /dev/null +++ b/.github/workflows/test_pyqt6.yml @@ -0,0 +1,154 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +# Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions + +name: Install and Test on Ubuntu (latest) with PyQt6 + +on: + push: + branches: [ "main", "develop", "release" ] + pull_request: + branches: [ "main", "develop", "release" ] + workflow_dispatch: + inputs: + job_to_run: + description: 'Which job to run' + required: true + type: choice + options: + - 'all' + - 'build' + - 'build_latest' + default: 'all' + schedule: + # Only the "build_latest" job runs on schedule (see execution conditions below) + - cron: "0 5 * * 1" + +jobs: + build: + if: ${{ (github.event_name == 'push' || github.event_name == 'pull_request') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build')) }} + + env: + DISPLAY: ':99.0' + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libegl1 + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + python -m pip install --upgrade pip + python -m pip install ruff pytest + pip install PyQt6 + if [ "${{ github.ref_name }}" = "develop" ]; then + # Clone and install development versions of key dependencies with editable install + cd .. + git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git + git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git + git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git + git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + cd SigimaX + pip install -e ../guidata + pip install -e ../PythonQwt + pip install -e ../plotpy + pip install -e ../sigima + # Install tomli for TOML parsing (safe if already present) + pip install tomli + # Extract dependencies and save to file, then install + python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" + pip install -r deps.txt + # Install SigimaX without dependencies + pip install --no-deps . + elif [ "${{ github.ref_name }}" = "release" ]; then + # Clone dependencies from release branches (with fallback to main/master) + cd .. + # Try cloning PythonQwt from main or master + git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/PythonQwt.git + # Try cloning guidata from release, fallback to main or master + git clone --depth 1 --branch release https://github.com/PlotPyStack/guidata.git || git clone --depth 1 https://github.com/PlotPyStack/guidata.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/guidata.git + # Try cloning plotpy from release, fallback to main or master + git clone --depth 1 --branch release https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 https://github.com/PlotPyStack/plotpy.git || git clone --depth 1 --branch master https://github.com/PlotPyStack/plotpy.git + # Try cloning sigima from release, fallback to main + git clone --depth 1 --branch release https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git + cd SigimaX + pip install -e ../guidata + pip install -e ../PythonQwt + pip install -e ../plotpy + pip install -e ../sigima + # Install tomli for TOML parsing (safe if already present) + pip install tomli + # Extract dependencies and save to file, then install + python -c "import tomli; f=open('pyproject.toml','rb'); data=tomli.load(f); deps=[d for d in data['project']['dependencies'] if not any(p in d for p in ['guidata','PlotPy','Sigima'])]; open('deps.txt','w').write('\n'.join(deps))" + pip install -r deps.txt + # Install SigimaX without dependencies + pip install --no-deps . + else + # Install from PyPI normally for main branch + pip install . + fi + - name: Lint with Ruff + run: ruff check --output-format=github sigimax + - name: Test with pytest + run: pytest -v --tb=long + + build_latest: + if: ${{ github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.job_to_run == 'all' || github.event.inputs.job_to_run == 'build_latest')) }} + env: + DISPLAY: ':99.0' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies (latest) + run: | + set -euxo pipefail + sudo apt-get update + sudo apt-get install -y \ + libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ + libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils libegl1 + + /sbin/start-stop-daemon --start --quiet \ + --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background \ + --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + + python -m pip install --upgrade pip + python -m pip install ruff pytest + python -m pip install PyQt6 + + # Clone and install Sigima from the same branch as SigimaX + cd .. + git clone --depth 1 --branch ${{ github.ref_name }} https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 https://github.com/DataLab-Platform/sigima.git + cd SigimaX + + # Install SigimaX itself, but do NOT install its pinned deps + python -m pip install -e . --no-deps + + # Install Sigima from local clone + python -m pip install -e ../sigima + + # Extract dependency names from pyproject.toml (excluding Sigima) and install latest versions + python -m pip install -U --upgrade-strategy eager $(python -c "import tomllib, re; print(' '.join(re.sub(r'[\[\]<>=!~,.\s].*$', '', d).strip() for d in tomllib.loads(open('pyproject.toml', 'rb').read().decode())['project']['dependencies'] if 'Sigima' not in d))") + + - name: Lint with Ruff (latest) + run: ruff check --output-format=github sigimax + + - name: Test with pytest (latest) + run: pytest -v --tb=long \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8debfd4..9052a59 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,12 @@ doc/contributing/changelog.md # Visual Studio Code .venv .env +.venv* +.*venv* + +#AI instructions +*.ai +*.ai* # Created by https://www.gitignore.io/api/python @@ -80,19 +86,12 @@ coverage.xml # Sphinx documentation docs/_build/ cdl/data/doc/ +doc/auto_examples/ +doc/sg_execution_times.rst # PyBuilder target/ -# Files related to WiX -.wix/ -wix/DataLab-*.wxs -wix/bin/ -wix/obj/ -wix/*.bmp -*.wixpdb -*.msi - # Files generated by tests scenario_*.h5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7c0e992 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.12.2 + hooks: + # Run the linter. + - id: ruff-check + args: [ --fix ] + # Run the formatter. + - id: ruff-format \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..e495f7f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,33 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.11" + apt_packages: + - xvfb + - libxkbcommon-x11-0 + - libxcb-icccm4 + - libxcb-image0 + - libxcb-keysyms1 + - libxcb-randr0 + - libxcb-render-util0 + - libxcb-xinerama0 + - libxcb-xfixes0 + jobs: + pre_build: + # Start Xvfb before building docs + - "Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &" + - "export DISPLAY=:99" +sphinx: + configuration: doc/conf.py +formats: + - pdf +python: + install: + - method: pip + path: . + extra_requirements: + - doc diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1c6cbc0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,51 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run current file", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env", + "justMyCode": false, + }, + { + "name": "Run current file (unattended)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env", + "pythonArgs": [ + "-W error::DeprecationWarning", + "-W error::RuntimeWarning", + ], + "justMyCode": false, + "args": [ + "--unattended", + ], + "env": { + // "DEBUG": "1", // ☣️ Debug mode will reset .ini settings + // "QT_QPA_PLATFORM": "offscreen", + // "SIGIMAX_DATA": "${workspaceFolder}/sigimax/data/tests", // TODO : Use test data folder + } + }, + { + "name": "Profile current file", + "type": "debugpy", + "request": "launch", + "module": "cProfile", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env", + "args": [ + "-o", + "${file}.prof", + "${file}" + ], + }, + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0080af9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + "[bat]": { + "files.encoding": "cp850" + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[restructuredtext]": { + "editor.wordWrap": "on" + }, + "editor.codeActionsOnSave": { + "source.organizeImports.ruff": "explicit" + }, + "editor.formatOnSave": true, + "editor.rulers": [ + 88 + ], + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true, + "**/*.pyo": true + }, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "python.analysis.autoFormatStrings": true, + "python.testing.pytestArgs": [], + "python.testing.pytestEnabled": true, + "python.testing.pytestPath": "pytest", + "python.testing.unittestEnabled": false, + "terminal.integrated.tabs.description": "${workspaceFolder}", + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:venv", + "packageManager": "ms-python.python:pip" + } + ], +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..b01df2f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,805 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "🧽 Ruff Formatter", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "ruff", + "format", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🔦 Ruff Linter", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "ruff", + "check", + "--fix", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🧽🔦 Ruff", + "dependsOrder": "sequence", + "dependsOn": [ + "🧽 Ruff Formatter", + "🔦 Ruff Linter", + ], + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🔦 Pylint", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "pylint", + "sigimax", + "--disable=duplicate-code", + "--disable=fixme", + "--disable=too-many-arguments", + "--disable=too-many-branches", + "--disable=too-many-instance-attributes", + "--disable=too-many-lines", + "--disable=too-many-locals", + "--disable=too-many-public-methods", + "--disable=too-many-statements", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🚀 Pytest", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "pytest", + "--ff", + // "--gui", + // "--show-windows", + ], + "options": { + "cwd": "${workspaceFolder}", + "env": { + // "DEBUG": "1", // ☣️ Debug mode will reset .ini settings + "UNATTENDED": "1", + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": true, + }, + "type": "shell", + }, + { + "label": "sphinx-build", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "sphinx", + "build", + "doc", + "build/gettext", + "-b", + "gettext", + "-W", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "sphinx-intl update", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "sphinx_intl", + "update", + "-d", + "doc/locale", + "-p", + "build/gettext", + "-l", + "fr", + "--no-obsolete", + "-w", + "0", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "sphinx-build", + ], + }, + { + "label": "cleanup-doc-translations", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "guidata.utils.translations", + "cleanup-doc", + "--directory", + ".", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "sphinx-intl update", + ], + }, + { + "label": "sphinx-intl build", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "sphinx_intl", + "build", + "-d", + "doc/locale", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🔎 Scan translations", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "guidata.utils.translations", + "scan", + "--name", + "sigimax", + "--directory", + ".", + "--copyright-holder", + "DataLab Platform Developers", + "--languages", + "fr", + ], + "group": { + "kind": "build", + "isDefault": false, + }, + "options": { + "cwd": "${workspaceFolder}", + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "cleanup-doc-translations", + ], + }, + { + "label": "📚 Compile translations", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "guidata.utils.translations", + "compile", + "--name", + "sigimax", + "--directory", + ".", + ], + "group": { + "kind": "build", + "isDefault": false, + }, + "options": { + "cwd": "${workspaceFolder}", + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "sphinx-intl build", + ], + }, + { + "label": "Generate requirements", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "guidata.utils.genreqs", + "all", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🧪 Coverage tests", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "coverage", + "run", + "-m", + "pytest", + "sigimax", + ], + "options": { + "cwd": "${workspaceFolder}", + "env": { + "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc", + }, + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "test", + "isDefault": true, + }, + "presentation": { + "panel": "dedicated", + }, + "problemMatcher": [], + }, + { + "label": "📊 Coverage full", + "type": "shell", + "windows": { + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine; if ($?) { ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html; if ($?) { start htmlcov\\index.html } }", + }, + "linux": { + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine && ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html && xdg-open htmlcov/index.html", + }, + "osx": { + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine && ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html && open htmlcov/index.html", + }, + "options": { + "cwd": "${workspaceFolder}", + "env": { + "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc", + }, + }, + "presentation": { + "panel": "dedicated", + }, + "problemMatcher": [], + "dependsOrder": "sequence", + "dependsOn": [ + "🧪 Coverage tests", + ], + }, + { + "label": "Upgrade PlotPyStack", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "pip", + "install", + "--upgrade", + "pip", + "PythonQwt", + "guidata", + "PlotPy", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false, + }, + "type": "shell", + }, + { + "label": "🔁 Reinstall guidata/plotpy/sigima dev", + "type": "shell", + "command": "${config:python.defaultInterpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${config:python.defaultInterpreterPath}", + "${workspaceFolder}/scripts/reinstall_dev.py", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "presentation": { + "panel": "dedicated", + "reveal": "always", + }, + "problemMatcher": [], + }, + { + "label": "🧹 Clean Up", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "guidata.utils.cleanup" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false, + }, + }, + { + "label": "📚 Build doc", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "sphinx", + "build", + "doc", + "${workspaceFolder}/build/doc", + "-b", + "html", + "-D", + "language=fr", + // "-W", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + "dependsOrder": "sequence", + "dependsOn": [ + "Generate requirements", + ], + }, + { + "label": "🌐 Open HTML doc", + "type": "shell", + "windows": { + "command": "start build/doc/index.html", + }, + "linux": { + "command": "xdg-open build/doc/index.html", + }, + "osx": { + "command": "open build/doc/index.html", + }, + "options": { + "cwd": "${workspaceFolder}", + }, + "problemMatcher": [], + }, + { + "label": "📦 Build package", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "guidata.utils.securebuild", + "--prebuild", + "python -m guidata.utils.translations compile --name sigimax --directory .", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "panel": "dedicated", + }, + "problemMatcher": [], + "dependsOrder": "sequence", + "dependsOn": [ + "🧹 Clean Up", + ], + }, + { + "label": "❔ Untracked files", + "type": "shell", + "command": "git ls-files --others | Where-Object { $_ -notmatch '^\\.' -and $_ -notmatch '^(build|dist|releases)/' -and $_ -notmatch '.(pyc|mo)$'}", + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": true, + }, + }, + { + "label": "🔄 Switch to PyQt5", + "type": "shell", + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip install --quiet PyQt5>=5.15.6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -c 'import qtpy; print(qtpy.API_NAME, qtpy.QT_VERSION)'", + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + { + "label": "🔄 Switch to PyQt6", + "type": "shell", + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip install --quiet PyQt6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -c 'import qtpy; print(qtpy.API_NAME, qtpy.QT_VERSION)'", + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + { + "label": "🔄 Switch to PySide6", + "type": "shell", + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m pip install --quiet PySide6; ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -c 'import qtpy; print(qtpy.API_NAME, qtpy.QT_VERSION)'", + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + { + "label": "🔄🗑️ Uninstall Qt bindings", + "type": "shell", + "command": "${command:python.interpreterPath} scripts/run_with_env.py python -m pip uninstall -y PyQt5 PyQt5-sip PyQt5-Qt5 PyQt6 PyQt6-sip PyQt6-Qt6 PySide6 shiboken6", + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + { + "label": "🧪 GitHub Actions local: list jobs (act)", + "type": "shell", + "command": "act", + "args": [ + "-l", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "test", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + { + "label": "🧪 GitHub Actions local: test_pyqt5.yml (pull_request)", + "type": "shell", + "command": "act", + "args": [ + "pull_request", + "-W", + ".github/workflows/test_pyqt5.yml", + "-P", + "ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latest", + "-v", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "test", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + { + "label": "🧪 GitHub Actions local: test_pyqt6.yml (pull_request)", + "type": "shell", + "command": "act", + "args": [ + "pull_request", + "-W", + ".github/workflows/test_pyqt6.yml", + "-P", + "ubuntu-latest=ghcr.io/catthehacker/ubuntu:full-latest", + "-v", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "test", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + }, + }, + ], +} \ No newline at end of file diff --git a/README.md b/README.md index c95318c..57aca37 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,212 @@ -# SigimaX +# SigimaX - Reusable GUI Framework for Scientific Applications -Placeholder for the future 'SigimaX' package, which will contain the computation and processing core of DataLab. +[![license](https://img.shields.io/pypi/l/sigimax.svg)](./LICENSE) +[![PyPI pyversions](https://img.shields.io/pypi/pyversions/sigimax.svg)](https://pypi.org/project/sigimax/) -This package name is reserved for future use. +**SigimaX** is an **open-source Python framework for building Qt-based scientific desktop applications**. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features. + +🔬 Developed by the [DataLab Platform Developers](https://github.com/DataLab-Platform), SigimaX is extracted from [DataLab](https://datalab-platform.com/) and powers its GUI layer. + +--- + +## 🌟 Project & Sponsors + +| Project/Sponsor | Description | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DataLab logo | Open-source platform for scientific signal and image processing, built on SigimaX. | +| NLnet logo | European non-profit supporting open-source and internet projects. SigimaX has received funding from NLnet for its development, through the DataLab project. | + +--- + +## ✨ Highlights + +- **Extensible configuration system** — `OptionField`-based settings with `get()`/`set()`/`context()` API and JSON persistence +- **Rich widget catalog** — 15 ready-to-use scientific widgets: curve fitting, peak detection, signal baseline, HDF5 browser, log viewer, wizard dialogs, and more +- **Complete HDF5 infrastructure** — built-in file browser, importer, and workspace save/load +- **Embedded Python console** — `DockableConsole` with error-to-console routing and configurable namespace +- **Production-grade status bar** — memory usage monitoring with alarm threshold, console toggle +- **PlotPy integration** — `DockablePlotWidget` and adapters for signal/image/ROI objects +- **Derivation pattern** — subclass `SigimaXOptions` + `SGMXMainWindow` + call `run()` to build a full app in minutes + +--- + +## 💡 Use Cases + +SigimaX is meant to be: + +- A **framework for building scientific desktop apps** with Qt +- A **reusable main window** with menus, toolbars, docks, and HDF5 workspace management +- A **widget library** for signal/image analysis dialogs (fitting, peak detection, baseline, cursor, delta-X) +- A **configuration backbone** for apps that need persistent user preferences + +--- + +## 📖 Design Philosophy + +SigimaX separates the **generic application skeleton** from **domain-specific logic**. Derived applications follow a three-step pattern: + +1. **Subclass `SigimaXOptions`** to add application-specific configuration fields +2. **Subclass `SGMXMainWindow`** to customize menus, toolbars, and dock widgets +3. **Call `sigimax.app.run()`** to launch the application with splash screen support + +This architecture is proven in production: [DataLab](https://datalab-platform.com/) is built entirely on this derivation pattern. + +### Position in the Stack + +```text +End-user apps (DataLab, custom scientific apps) + ↓ subclass / configure + SigimaX ← THIS PROJECT (framework layer) + ↓ depends on + Sigima (computation) + PlotPy + guidata + PythonQwt + ↓ + NumPy / SciPy / Qt +``` + +--- + +## 🚀 Quick Start + +```python +from sigimax.app import run +from sigimax.config import CONF as Conf, SigimaXOptions, EnumOptionField, _ +from sigimax.mainwindow import SGMXMainWindow +from sigimax.widgets.plotdock import DockablePlotWidget +from sigimax.config import TypedOptionField +from plotpy.constants import PlotType + +# A missing option may be initialized on first read: +color_mode = Conf.color_mode.get("auto") + + +# 1. Define custom options +class MyAppOptions(SigimaXOptions): + def __init__(self): + super().__init__() + self.app_name.set("MyApp") + self.greeting = TypedOptionField( + self, "greeting", default="Hello!", + expected_type=str, description="Startup message", + ) + + +# 2. Customize the main window +class MyAppMainWindow(SGMXMainWindow): + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("MyApp") + super().__init__(console=console, hide_on_close=hide_on_close) + # Add a dockable curve plot + self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) + dock, loc = self.curve_dock.create_dockwidget(_("Curve Viewer")) + self.addDockWidget(loc, dock) + + +# 3. Launch +run(window_class=MyAppMainWindow) +``` + +--- + +## ⚙️ Architecture + +```text +sigimax/ +├── app.py # Application launcher (create / run) +├── config.py # Configuration system (SigimaXOptions, CONF singleton) +├── env.py # Runtime environment (verbosity, unattended mode) +├── mainwindow.py # SGMXMainWindow (generic main window) +├── widgets/ # Reusable Qt widgets +│ ├── plotdock.py # DockablePlotWidget +│ ├── splashscreen.py # Configurable splash screen +│ ├── h5browser.py # HDF5 file browser +│ ├── logviewer.py # Log viewer dialog +│ ├── status.py # Status bar widgets (memory, console) +│ ├── fitdialog.py # Curve fitting dialogs +│ ├── signalpeak.py # Signal peak detection +│ ├── signalbaseline.py # Signal baseline selection +│ ├── signalcursor.py # Signal cursor selection +│ ├── signaldeltax.py # Signal delta-X measurement +│ ├── wizard.py # Multi-page wizard dialog +│ └── ... # File dialogs, warning/error boxes +├── h5/ # HDF5 I/O (read/write/import) +├── adapters_plotpy/ # Converters between PlotPy/guidata and Sigima objects +├── utils/ # Qt helpers, config dir resolution +├── data/ # Icons, resources +└── locale/ # Translations (EN, FR) +``` + +--- + +## 📦 Installation + +```bash +pip install sigimax +``` + +Or in a development environment: + +```bash +git clone https://github.com/DataLab-Platform/SigimaX.git +cd SigimaX +pip install -e . +``` + +--- + +## 📚 Documentation + +📖 Full documentation (in progress) is available at: +👉 + +> Want to use SigimaX as part of the full DataLab platform? +> Check out: [DataLab](https://datalab-platform.com/) + +--- + +## 🧪 Testing + +SigimaX comes with a comprehensive test suite based on `pytest` (155 tests). + +### ✅ Validated Environments + +The test suite has been checked with the following matrix: + +- **Python**: 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 +- **Operating systems**: Windows, Linux +- **Qt bindings**: PyQt5, PyQt6, PySide6 (future fix needed) + +> ⚠️ Note: PySide6 is currently known to be not fully working in this matrix. + +```bash +# Run all tests (offscreen, no GUI) +python scripts/run_with_env.py python -m pytest + +# Show Qt windows during tests +python scripts/run_with_env.py python -m pytest --show-windows +``` + +--- + +## 🧠 License + +SigimaX is distributed under the terms of the BSD 3-Clause license. +See [LICENSE](./LICENSE) for details. + +--- + +## 🤝 Contributing + +Bug reports, feature requests and pull requests are welcome! +See the [CONTRIBUTING](https://datalab-platform.com/en/contributing) guide to get started. + +--- + +![Python](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/Python.png) +![NumPy](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/NumPy.png) +![SciPy](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/SciPy.png) +![scikit-image](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/scikit-image.png) +![OpenCV](https://raw.githubusercontent.com/DataLab-Platform/DataLab/main/doc/images/logos/OpenCV.png) + +--- + +© DataLab Platform Developers diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..b46a78c --- /dev/null +++ b/babel.cfg @@ -0,0 +1,4 @@ +# This file is used to configure Babel for the project. + +[python: **.py] +encoding = utf-8 diff --git a/datalab_module_to_import.md b/datalab_module_to_import.md new file mode 100644 index 0000000..7382a7c --- /dev/null +++ b/datalab_module_to_import.md @@ -0,0 +1,80 @@ +# SigimaX - DataLab modules to import + +## Main purpose + +The main purpose of the SigimaX library is to extract generic application components from DataLab into an independent module, facilitating the creation of scientific applications. + +This file lists all identified DataLab modules and shows which ones will be integrated into the SigimaX library. + +## Features goal list + +Here is the functional content targeted for the SigimaX library (cf. NLnet project specs): + +- **Application configuration management**: settings, parameters, preferences +- **Log handler**: logger, dock display, verbosity (information) +- **Files/projects history**: recent files/projects tracking +- **Main window base structure**: menus, toolbars, docks +- **HDF5 Explorer**: IO module and widget +- **Reusable GUI widgets**: a subset of those defined in `datalab.widgets` +- **Resources management**: icons, paths, locale and translations +- **Standard dialog infrastructure**: e.g., application welcome screen + +## Modules + +Legend: ✅ = Include in SigimaX | ❌ = Exclude (DataLab-specific) | ❓ = To be discussed | ⚠️ = Proceed with caution + +| Module | ⬇️ | Description | Note | Dependencies | +| ----------------------- | --- | ------------------------------------------------------------------------ | ---------------------------------------------------- | ------------------------------- | +| adapters_metadata | ❌ | Sigima adapters TableResult, GeometryResult -> SignalObject, ImageObject | | sigima | +| adapters_plotpy | ✅⚠️ | Adapters/converters for PlotPy, guidata objects <-> Sigima objects | Check consistency with `sigima.viz` (future feature) | sigima, plotpy, guidata | +| control | ❌ | XML-RPC remote control | DataLab-specific | | +| data/icons | ✅ | SVG icons used in widgets and windows | Case-by-case | | +| data/logo | ❌ | DataLab app logo | DataLab-specific | | +| data/tests | ✅❓ | H5 empty and test files | Case-by-case | | +| data/tutorials | ❌ | JPG images used for tutorials | DataLab-specific | | +| gui/actionhandler | ❌ | Module handles app actions (menus, toolbars, context menu, ...) | DataLab-specific | sigima, guidata, qt | +| gui/docks | ✅⚠️ | Module provides the dockable widgets for main window | Minimal support | guidata, plotpy, qt, sigima | +| gui/h5io | ❌ | Module provides H5 open/save into/from data model/main window | DataLab-specific | guidata, qt, sigima, h5 | +| gui/macroeditor | ❌ | Module provides the macro editor widget (Python console) | DataLab-specific | guidata, qt, env | +| gui/main | ✅❓ | Module provides the main window | Extract a generic main window | guidata, plotpy, qt, sigima | +| gui/newobject | ❌ | Module provides new object creation GUI (signals and images) | | guidata, plotpy, qt, sigima | +| gui/objectview | ❌ | Widgets to display object (signal/image) trees | | guidata, qt, sigima | +| gui/panel | ❌ | GUI panel objects: Signal Panel, Image Panel, Macro Panel | | qt, h5, guidata, plotpy, sigima | +| gui/plothandler | ❌ | Handling PlotPy plot items for representing signals and images | | plotpy, qt, sigima | +| gui/processor | ❌ | Processor objects (link between Sigima and GUI) | | qt, guidata, plotpy, sigima | +| gui/profiledialog | ❌ | Profile extraction dialog | | guidata, plotpy, qt, sigima | +| gui/roieditor | ❌ | ROI editor widgets for signals and images | Consider moving to widgets/ | guidata, plotpy, qt, sigima | +| gui/roigrideditor | ❌ | ROI grid editor for structured ROI management | Related to roieditor.py | guidata, plotpy, qt, sigima | +| gui/settings | ❓ | Module for app settings dialog and related classes | Future feature | guidata, qt, plotpy | +| gui/tour | ❌ | GUI DataLab tour features (tutorials, demo) | DataLab-specific | | +| h5 | ✅ | HDF5 IO module file handler (read/write) | ⚠️ 'h5/native': DataLab-specific | h5py | +| locale | ✅ | Translations (EN-FR) | | | +| plugins | ❌ | DataLab plugins system (directory) | DataLab-specific | | +| plugins.py | ❌ | Plugin base classes and registry | DataLab-specific | | +| tests | ✅❓ | Test units | Case-by-case | | +| utils/conf | ✅ | Configuration utilities | | qt, guidata | +| utils/dephash | ❌ | Module checking dependencies with respect to a reference | DataLab-specific | | +| utils/qthelpers | ✅ | Qt utilities | | qt, guidata | +| utils/strings | ✅❓ | Generates HTML diff between two strings (used in H5 test units) | | | +| utils/tests | ✅❓ | Test utilities | Case-by-case | | +| webapi | ❌ | Web API module | DataLab-specific | | +| widgets/connection | ❌ | Connection dialog for proxy client (remote control) | DataLab-specific | | +| widgets/filedialog | ✅ | File dialog widget (enhanced QFileDialog with multi-file preselection) | | guidata, qt | +| widgets/fileviewer | ✅ | File viewer widget | | guidata, qt | +| widgets/fitdialog | ✅ | Curve fitting dialog widgets | | guidata, plotpy, sigima | +| widgets/h5browser | ✅⚠️ | HDF5 browser module | | guidata, plotpy, qt, sigima, h5 | +| widgets/imagebackground | ✅ | Image background selection dialog | | guidata, plotpy, qt, sigima | +| widgets/instconfviewer | ❌ | Installation configuration widget | Linked with DataLab plugin system | | +| widgets/logviewer | ✅ | Log viewer widget | | guidata, qt, env | +| widgets/signalbaseline | ✅ | Signal baseline selection dialog | | guidata, plotpy, sigima, qt | +| widgets/signalcursor | ✅ | Signal H/V cursor selection dialog | | guidata, qt, plotpy, sigima | +| widgets/signaldeltax | ✅ | GUI dialog for analyzing signals and calculating full width at Y | | guidata, plotpy, qt, sigima | +| widgets/signalpeak | ✅ | Signal peak detection feature dialog | | guidata, plotpy, qt, sigima | +| widgets/status | ✅ | Main window status bar widgets | Only `MemoryStatus`, `ConsoleStatus` | guidata, qt, plugins | +| widgets/textimport | ❌ | Text Import Wizard | | guidata, plotpy, qt, sigima | +| widgets/warningerror | ✅ | Warning/error message dialog box | | guidata, qt | +| widgets/wizard | ✅ | Wizard widget (enhanced QWizard with complete styling support) | | qt | +| app.py | ❌ | Application launcher | DataLab-specific | | +| config.py | ✅❓ | Application configuration | Must be made generic | sigima, plotpy, guidata | +| env.py | ✅ | Environment utilities | | guidata | +| objectmodel.py | ❌ | Object model definitions | | sigima | diff --git a/doc/_static/DataLab-Banner.svg b/doc/_static/DataLab-Banner.svg new file mode 100644 index 0000000..e99cfee --- /dev/null +++ b/doc/_static/DataLab-Banner.svg @@ -0,0 +1,104 @@ + + + +image/svg+xmlDataLab diff --git a/doc/_static/DataLab-Title.svg b/doc/_static/DataLab-Title.svg new file mode 100644 index 0000000..79762ff --- /dev/null +++ b/doc/_static/DataLab-Title.svg @@ -0,0 +1,94 @@ + + + +image/svg+xmlDataLab diff --git a/doc/_static/DataLab.svg b/doc/_static/DataLab.svg new file mode 100644 index 0000000..d884fbf --- /dev/null +++ b/doc/_static/DataLab.svg @@ -0,0 +1,83 @@ + + + +image/svg+xml diff --git a/doc/_static/Sigima-Frontpage.png b/doc/_static/Sigima-Frontpage.png new file mode 100644 index 0000000..1166923 Binary files /dev/null and b/doc/_static/Sigima-Frontpage.png differ diff --git a/doc/_static/Sigima-Title.svg b/doc/_static/Sigima-Title.svg new file mode 100644 index 0000000..1200353 --- /dev/null +++ b/doc/_static/Sigima-Title.svg @@ -0,0 +1,135 @@ + + + +image/svg+xmlSigima diff --git a/doc/_static/codra.png b/doc/_static/codra.png new file mode 100644 index 0000000..84ca90e Binary files /dev/null and b/doc/_static/codra.png differ diff --git a/doc/_static/favicon.ico b/doc/_static/favicon.ico new file mode 100644 index 0000000..b435c89 Binary files /dev/null and b/doc/_static/favicon.ico differ diff --git a/doc/_static/pypi.svg b/doc/_static/pypi.svg new file mode 100644 index 0000000..6508b58 --- /dev/null +++ b/doc/_static/pypi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/api/adapters_plotpy.rst b/doc/api/adapters_plotpy.rst new file mode 100644 index 0000000..d205388 --- /dev/null +++ b/doc/api/adapters_plotpy.rst @@ -0,0 +1,2 @@ +.. automodule:: sigimax.adapters_plotpy + :no-members: diff --git a/doc/api/app.rst b/doc/api/app.rst new file mode 100644 index 0000000..314d207 --- /dev/null +++ b/doc/api/app.rst @@ -0,0 +1,2 @@ +.. automodule:: sigimax.app + :no-members: diff --git a/doc/api/config.rst b/doc/api/config.rst new file mode 100644 index 0000000..5edb2f9 --- /dev/null +++ b/doc/api/config.rst @@ -0,0 +1,2 @@ +.. automodule:: sigimax.config + :no-members: diff --git a/doc/api/env.rst b/doc/api/env.rst new file mode 100644 index 0000000..7601990 --- /dev/null +++ b/doc/api/env.rst @@ -0,0 +1,2 @@ +.. automodule:: sigimax.env + :no-members: diff --git a/doc/api/h5.rst b/doc/api/h5.rst new file mode 100644 index 0000000..e8cade1 --- /dev/null +++ b/doc/api/h5.rst @@ -0,0 +1,2 @@ +.. automodule:: sigimax.h5 + :no-members: diff --git a/doc/api/index.rst b/doc/api/index.rst new file mode 100644 index 0000000..dc88b5e --- /dev/null +++ b/doc/api/index.rst @@ -0,0 +1,62 @@ +.. _api: + +API +=== + +The public Application Programming Interface (API) of SigimaX provides +the building blocks for creating scientific desktop applications. + +.. list-table:: + :header-rows: 1 + :align: left + + * - Module + - Purpose + + * - :mod:`sigimax.app` + - Application launcher — ``create()`` and ``run()`` entry points for + starting a SigimaX-based application with splash screen support. + + * - :mod:`sigimax.config` + - Configuration system — ``SigimaXOptions`` singleton (``CONF``), + typed option fields (``EnumOptionField``, ``TupleOptionField``, + ``FontOptionField``), and translation function ``_()``. + + * - :mod:`sigimax.env` + - Runtime environment — ``SGMXExecEnv`` singleton (``execenv``) for + controlling unattended mode, verbosity, demo mode, and screenshots. + + * - :mod:`sigimax.mainwindow` + - Generic main window — ``SGMXMainWindow`` with customizable menus, + toolbars, console, HDF5 workspace, and status bar. + + * - :mod:`sigimax.widgets` + - Reusable Qt widgets — scientific dialogs (fitting, peak detection, + baseline, cursor, delta-X), HDF5 browser, log viewer, wizard, + splash screen, status bar, and dockable plot widgets. + + * - :mod:`sigimax.h5` + - HDF5 I/O — import, read, write, and browse HDF5 files with node + factory and data extraction utilities. + + * - :mod:`sigimax.adapters_plotpy` + - PlotPy adapters — converters between Sigima objects + (``SignalObj``, ``ImageObj``, ROIs) and PlotPy plot/annotation items. + + * - :mod:`sigimax.utils` + - Utilities — Qt helpers (log management, signal blocking, progress bars), + configuration directory resolution, and callback workers. + + +.. toctree:: + :maxdepth: 2 + :caption: Public modules: + + app + config + env + mainwindow + widgets + h5 + adapters_plotpy + utils diff --git a/doc/api/mainwindow.rst b/doc/api/mainwindow.rst new file mode 100644 index 0000000..9652d3b --- /dev/null +++ b/doc/api/mainwindow.rst @@ -0,0 +1,2 @@ +.. automodule:: sigimax.mainwindow + :no-members: diff --git a/doc/api/utils.rst b/doc/api/utils.rst new file mode 100644 index 0000000..cc277ae --- /dev/null +++ b/doc/api/utils.rst @@ -0,0 +1,14 @@ +:mod:`sigimax.utils` --- Utilities +=================================== + +.. automodule:: sigimax.utils + :no-members: + +Submodules +---------- + +.. automodule:: sigimax.utils.qthelpers + :no-members: + +.. automodule:: sigimax.utils.conf + :no-members: diff --git a/doc/api/widgets.rst b/doc/api/widgets.rst new file mode 100644 index 0000000..18efedd --- /dev/null +++ b/doc/api/widgets.rst @@ -0,0 +1,53 @@ +:mod:`sigimax.widgets` --- Reusable Qt Widgets +============================================== + +.. automodule:: sigimax.widgets + :no-members: + +Submodules +---------- + +.. automodule:: sigimax.widgets.plotdock + :no-members: + +.. automodule:: sigimax.widgets.splashscreen + :no-members: + +.. automodule:: sigimax.widgets.h5browser + :no-members: + +.. automodule:: sigimax.widgets.logviewer + :no-members: + +.. automodule:: sigimax.widgets.status + :no-members: + +.. automodule:: sigimax.widgets.fitdialog + :no-members: + +.. automodule:: sigimax.widgets.signalpeak + :no-members: + +.. automodule:: sigimax.widgets.signalbaseline + :no-members: + +.. automodule:: sigimax.widgets.signalcursor + :no-members: + +.. automodule:: sigimax.widgets.signaldeltax + :no-members: + +.. automodule:: sigimax.widgets.imagebackground + :no-members: + +.. automodule:: sigimax.widgets.wizard + :no-members: + +.. automodule:: sigimax.widgets.warningerror + :no-members: + +.. automodule:: sigimax.widgets.filedialog + :no-members: + +.. automodule:: sigimax.widgets.fileviewer + :no-members: diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..15a35cd --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,212 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +# pylint: skip-file + +import os +import os.path as osp +import sys + +import guidata.config as gcfg +from docutils import nodes +from docutils.parsers.rst import Directive +from docutils.statemachine import StringList +from guidata.utils import qt_scraper + + +class OptionsTableDirective(Directive): + """Custom directive to include dynamically generated options table.""" + + has_content = False + + def run(self): + """Generate and include the options table.""" + from sigimax.config import CONF + + # Get the RST content + rst_content = CONF.generate_rst_doc() + + # Create a container node + container = nodes.container() + + # Parse the RST content and add it to the container + rst_lines = rst_content.splitlines() + string_list = StringList(rst_lines) + self.state.nested_parse(string_list, self.content_offset, container) + + return [container] + + +sys.path.insert(0, os.path.abspath("..")) + +import sigimax + +# Turn off validation of guidata config +# (documentation build is not the right place for validation) +gcfg.set_validation_mode(gcfg.ValidationMode.DISABLED) + + +def exclude_api_from_gettext(app): + """Exclude detailed API docs from gettext extraction. + + This excludes API docs but keeps api/index.rst for translation. + """ + if app.builder.name == "gettext": + # Get all RST files in the api directory + api_dir = osp.join(app.srcdir, "api") + if osp.exists(api_dir): + for filename in os.listdir(api_dir): + if filename.endswith(".rst") and filename != "index.rst": + # Remove .rst extension and add wildcard + pattern = f"api/{filename[:-4]}*" + if pattern not in app.config.exclude_patterns: + app.config.exclude_patterns.append(pattern) + + # Also check subdirectories (may be useful in the future) + for dirname in os.listdir(api_dir): + subdir_path = osp.join(api_dir, dirname) + if osp.isdir(subdir_path): + # Exclude entire subdirectories except their index files + pattern = f"api/{dirname}/*" + if pattern not in app.config.exclude_patterns: + app.config.exclude_patterns.append(pattern) + + # Suppress warnings about excluded API documents during gettext builds + app.config.suppress_warnings.extend(["toc.excluded", "ref.doc"]) + + +def setup(app): + """Setup function for Sphinx.""" + app.add_directive("options-table", OptionsTableDirective) + app.connect("builder-inited", exclude_api_from_gettext) + + +# -- Project information ----------------------------------------------------- + +project = "SigimaX" +author = "" +copyright = "2025, DataLab Platform Developers" +release = sigimax.__version__ + +# -- General configuration --------------------------------------------------- + +extensions = [ + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.mathjax", + "sphinx.ext.githubpages", + "sphinx.ext.viewcode", + "myst_nb", # Replaces myst_parser and adds notebook support + "sphinx_design", + "sphinx_copybutton", + "guidata.dataset.autodoc", + "sphinx_gallery.gen_gallery", +] + +# MyST-NB configuration +nb_execution_mode = "off" # Don't re-execute notebooks during build +nb_execution_timeout = 180 # Timeout for notebook execution (if enabled) + +templates_path = ["_templates"] +exclude_patterns = [ + "sg_execution_times.rst", + "**/sg_execution_times.rst", + # exclude .py and .ipynb files in auto_examples generated by sphinx-gallery + # this is to prevent sphinx from complaining about duplicate source files + "auto_examples/**/*.ipynb", + "auto_examples/**/*.py", +] + +# Suppress the warning about unpicklable sphinx_gallery_conf +# (it contains reset_modules function which cannot be pickled) +suppress_warnings = ["config.cache"] + +# -- Sphinx-Gallery configuration -------------------------------------------- +# Using guidata's generic Qt scraper for capturing all Qt widgets +# Configure to use the last widget as thumbnail for a complete pipeline view +qt_scraper.set_qt_scraper_config( + thumbnail_source="last", hide_toolbars=True, capture_inside_layout=True +) +sphinx_gallery_conf = qt_scraper.get_sphinx_gallery_conf( + filename_pattern="", min_reported_time=60, show_memory=False +) + +if "READTHEDOCS" in os.environ or "CI" in os.environ: + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +sphinx_gallery_conf["subsection_order"] = [ + "./examples/getting_started", + "./examples/features", + "./examples/use_cases", +] +sphinx_gallery_conf["within_subsection_order"] = "ExampleTitleSortKey" +# Note: The handler also cleans up the stub server from previous examples + +# -- Options for HTML output ------------------------------------------------- +html_theme = "pydata_sphinx_theme" +html_title = project +html_logo = "images/SigimaX-Banner.svg" +html_favicon = "_static/favicon.ico" +html_show_sourcelink = False +templates_path = ["_templates"] +# if "language=fr" in sys.argv: +# ann = "" # noqa: E501 +# else: +# ann = "" # noqa: E501 +html_theme_options = { + "show_toc_level": 2, + "github_url": "https://github.com/DataLab-Platform/SigimaX/", + "logo": { + "text": f"v{sigimax.__version__}", + }, + "icon_links": [ + { + "name": "PyPI", + "url": "https://pypi.org/project/sigimax", + "icon": "_static/pypi.svg", + "type": "local", + "attributes": {"target": "_blank"}, + }, + { + "name": "CODRA", + "url": "https://codra.net", + "icon": "_static/codra.png", + "type": "local", + "attributes": {"target": "_blank"}, + }, + { + "name": "DataLab", + "url": "https://datalab-platform.com", + "icon": "_static/DataLab.svg", + "type": "local", + "attributes": {"target": "_blank"}, + }, + ], + # "announcement": ann, +} +html_static_path = ["_static"] + +# -- Options for LaTeX output ------------------------------------------------ +latex_logo = "_static/Sigima-Frontpage.png" + +# -- Options for sphinx-intl package ----------------------------------------- +locale_dirs = ["locale/"] # path is example but recommended. +gettext_compact = False +gettext_location = False + +# -- Options for autodoc extension ------------------------------------------- +autodoc_default_options = { + "members": True, + "member-order": "bysource", +} + +# -- Options for intersphinx extension --------------------------------------- +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "scipy": ("https://docs.scipy.org/doc/scipy/", None), + "scikit-image": ("https://scikit-image.org/docs/stable/", None), + "guidata": ("https://guidata.readthedocs.io/en/latest/", None), + "plotpy": ("https://plotpy.readthedocs.io/en/latest/", None), + "sigima": ("https://sigima.readthedocs.io/en/latest/", None), +} diff --git a/doc/contributing/index.rst b/doc/contributing/index.rst new file mode 100644 index 0000000..a501dea --- /dev/null +++ b/doc/contributing/index.rst @@ -0,0 +1,122 @@ +Contributing +============ + +.. meta:: + :description: Contribute to SigimaX project, the open-source GUI framework for scientific applications + :keywords: SigimaX, contribute, open-source, scientific, GUI, framework, Qt, Python + +There are many ways to contribute to SigimaX, depending on how much time you +have, your experience with open source projects, and your skills. + +Share your ideas and experiences +-------------------------------- + +.. only:: html and not latex + + :octicon:`info;1em;sd-text-info` :bdg-success-line:`No coding required` + +Besides the classic bug reports and feature requests, you can share your ideas and +experiences for improving SigimaX. In particular, we are very interested in your +feedback on the documentation and tutorials. Moreover, if you have a use case that +you would like to share with the community, please let us know. + +.. only:: html and not latex + + .. grid:: 2 + :gutter: 1 2 3 4 + + .. grid-item-card:: :octicon:`bug;1em;sd-text-info` Bugs + :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title= + + Reporting a bug + + .. grid-item-card:: :octicon:`light-bulb;1em;sd-text-info` Enhancements + :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title= + + Suggesting an enhancement + + .. grid-item-card:: :octicon:`book;1em;sd-text-info` Documentation + :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=documentation&projects=&template=doc_request.md&title= + + Suggesting a documentation topic + + .. grid-item-card:: :octicon:`mortar-board;1em;sd-text-info` Tutorial + :link: https://github.com/DataLab-Platform/SigimaX/issues/new?assignees=&labels=documentation&projects=&template=tutorial_request.md&title= + + Suggesting a tutorial topic + + +.. only:: latex and not html + + Without coding, you can contribute to SigimaX project by: + + - `Reporting a bug `_ + - `Suggesting an enhancement `_ + - `Suggesting a documentation topic `_ + - `Suggesting a tutorial topic `_ + +Share your scientific/technical knowledge +----------------------------------------- + +.. only:: html and not latex + + :octicon:`info;1em;sd-text-info` :bdg-success-line:`No coding required` + +Your technical or scientific knowledge is also very valuable to us. You may +contribute documentation or tutorials directly. Or, if you want to write a +tutorial, we will be happy to help you get started. + +Without coding, you can contribute to SigimaX project by: + +- Writing documentation +- Writing a tutorial +- Sharing a use case of a derived application + +Contribute code +--------------- + +.. only:: html and not latex + + :octicon:`info;1em;sd-text-info` :bdg-info-line:`Coding (beginner)` :bdg-warning-line:`Coding (advanced)` + +Even if you are not an experienced developer, you can contribute to the project by: + +- Testing new features +- Writing or improving tests +- Reporting and fixing bugs + +If you are a developer, you can contribute to the core of the project by fixing +bugs or implementing new features. + +Development setup +^^^^^^^^^^^^^^^^^ + +1. Clone the repository: + + .. code-block:: console + + $ git clone https://github.com/DataLab-Platform/SigimaX.git + $ cd SigimaX + $ pip install -e .[dev,doc] + +2. Run the tests: + + .. code-block:: console + + $ python scripts/run_with_env.py python -m pytest + +3. Format and lint: + + .. code-block:: console + + $ python -m ruff format + $ python -m ruff check --fix + +Code conventions +^^^^^^^^^^^^^^^^ + +- Use ``from __future__ import annotations`` in all modules +- Define ``__all__`` in all public modules +- Wrap UI strings with ``_()`` for internationalization +- Follow Google-style docstrings +- Use ``snake_case`` for functions, ``PascalCase`` for classes diff --git a/doc/examples/README.txt b/doc/examples/README.txt new file mode 100644 index 0000000..e47fa77 --- /dev/null +++ b/doc/examples/README.txt @@ -0,0 +1,14 @@ +Examples +======== + +This section presents a collection of examples demonstrating the capabilities of SigimaX. +Each example is a standalone Python script that showcases specific features or use cases +of the SigimaX library. + +Of course, some of these examples may seem trivial, but they serve to illustrate how to use +various functionalities of SigimaX in a clear and concise manner. + +.. note:: + + These examples are automatically generated when building the documentation, thus + ensuring that they are always up-to-date with the latest version of SigimaX. diff --git a/doc/examples/features/README.txt b/doc/examples/features/README.txt new file mode 100644 index 0000000..030a1bb --- /dev/null +++ b/doc/examples/features/README.txt @@ -0,0 +1,2 @@ +Various Features +---------------- diff --git a/doc/examples/features/adapters_plotpy_factory.py b/doc/examples/features/adapters_plotpy_factory.py new file mode 100644 index 0000000..44022c8 --- /dev/null +++ b/doc/examples/features/adapters_plotpy_factory.py @@ -0,0 +1,105 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Factory +======================== + +SigimaX converts Sigima objects (:class:`~sigima.objects.SignalObj`, +:class:`~sigima.objects.ImageObj`, ROIs) to/from PlotPy plot items through a +small set of adapter classes. Which adapter class is used for a given object +is resolved by :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` +— a single, overridable indirection point. + +This is useful when a derived application: + +- adds a **new object type** that needs its own PlotPy rendering, or +- wants to **substitute** one of SigimaX's built-in adapters (e.g. to draw + images with a custom colormap policy) without touching SigimaX itself. +""" + +# %% +# Importing necessary modules +# --------------------------- + +import numpy as np +from sigima import SignalObj + +from sigimax.adapters_plotpy import ( + SignalObjPlotPyAdapter, + create_adapter_from_object, +) +from sigimax.adapters_plotpy.factories import ( + PlotPyAdapterFactory, + get_adapter_factory, + reset_adapter_factory, + set_adapter_factory, +) + +# %% +# Default resolution +# -------------------- +# +# :func:`~sigimax.adapters_plotpy.create_adapter_from_object` asks the +# *currently active* factory +# (:func:`~sigimax.adapters_plotpy.factories.get_adapter_factory`) +# for the right adapter class, then instantiates it. Out of the box, this is +# a :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` that +# dispatches on the Sigima object type. + +signal = SignalObj() +signal.set_xydata(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100))) +signal.title = "Demo signal" + +adapter = create_adapter_from_object(signal) +print(f"Adapter class: {type(adapter).__name__}") +assert isinstance(adapter, SignalObjPlotPyAdapter) + +item = adapter.make_item() +print(f"PlotPy item: {type(item).__name__}") + +# %% +# Overriding the factory +# ------------------------ +# +# Subclass :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` +# and override +# :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class`, +# delegating to ``super()`` for the types you don't need to change. Install it +# with :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` so that +# every SigimaX component (dock widgets, HDF5 browser preview, ROI editing) +# picks it up transparently. + + +class LoggingAdapterFactory(PlotPyAdapterFactory): + """Adapter factory that logs every resolution (for demonstration).""" + + def get_adapter_class(self, object_to_adapt) -> type: + adapter_class = super().get_adapter_class(object_to_adapt) + print(f"Resolved {type(object_to_adapt).__name__} -> {adapter_class.__name__}") + return adapter_class + + +set_adapter_factory(LoggingAdapterFactory()) +try: + adapter = create_adapter_from_object(signal) + print(f"Active factory: {type(get_adapter_factory()).__name__}") +finally: + # Always restore the base factory so later examples/tests are unaffected + reset_adapter_factory() + +# %% +# Summary +# ------- +# +# - :func:`~sigimax.adapters_plotpy.create_adapter_from_object` is the single +# entry point application code should use to go from a Sigima object to a +# PlotPy adapter +# - :func:`~sigimax.adapters_plotpy.factories.get_adapter_factory` / +# :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` let a +# derived application install its own factory once, globally +# - Override +# :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class` +# for object-to-item resolution and ``get_adapter_class_for_plot_item()`` +# for the reverse (item-to-ROI) direction +# - Call :func:`~sigimax.adapters_plotpy.factories.reset_adapter_factory` to +# restore the SigimaX base factory (mostly useful in tests) diff --git a/doc/examples/features/configuration.py b/doc/examples/features/configuration.py new file mode 100644 index 0000000..1b3e2fc --- /dev/null +++ b/doc/examples/features/configuration.py @@ -0,0 +1,199 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Configuration System +==================== + +This example demonstrates SigimaX's configuration system — typed option fields +with ``get()``/``set()``/``context()`` API, JSON persistence, and validation. + +The configuration system is the backbone of any SigimaX-based application. +It provides: + +- **Type safety**: Options are validated on set +- **Context managers**: Temporary overrides that auto-restore +- **Serialization**: JSON round-trip for persistence +- **Enum constraints**: Options restricted to specific choices +""" + +# %% +# Importing necessary modules +# --------------------------- + +import os.path as osp +import tempfile + +from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField + +# %% +# Creating a custom configuration +# -------------------------------- +# +# Subclass :class:`~sigimax.config.SigimaXOptions` and add typed fields. + + +class DemoOptions(SigimaXOptions): + """Demo configuration with various option types.""" + + def __init__(self): + super().__init__() + self.app_name.set("ConfigDemo") + + self.iterations = TypedOptionField( + self, + "iterations", + default=100, + expected_type=int, + description="Number of iterations", + ) + self.precision = TypedOptionField( + self, + "precision", + default=1e-6, + expected_type=float, + description="Convergence precision", + ) + self.algorithm = EnumOptionField( + self, + "algorithm", + default="gradient", + choices=["gradient", "newton", "simplex"], + description="Optimization algorithm", + ) + self.verbose = TypedOptionField( + self, + "verbose_mode", + default=False, + expected_type=bool, + description="Enable verbose logging", + ) + + # Capture defaults for reset support + self._defaults.update( + { + name: getattr(self, name).get() + for name in ("iterations", "precision", "algorithm", "verbose") + } + ) + + +# %% +# Basic get/set operations +# ------------------------- + +conf = DemoOptions() + +print("=== Basic get/set ===") +print(f"Iterations: {conf.iterations.get()}") +print(f"Algorithm: {conf.algorithm.get()}") + +conf.iterations.set(500) +conf.algorithm.set("newton") +print(f"Updated iterations: {conf.iterations.get()}") +print(f"Updated algorithm: {conf.algorithm.get()}") + +# %% +# Context manager for temporary overrides +# ----------------------------------------- +# +# The ``context()`` method temporarily overrides an option and automatically +# restores the previous value when leaving the block. + +print("\n=== Context manager ===") +print(f"Before context: iterations = {conf.iterations.get()}") + +with conf.iterations.context(10): + print(f"Inside context: iterations = {conf.iterations.get()}") + +print(f"After context: iterations = {conf.iterations.get()}") + +# %% +# Enum validation +# ---------------- +# +# ``EnumOptionField`` rejects values not in the allowed choices. + +print("\n=== Enum validation ===") +try: + conf.algorithm.set("invalid_algorithm") + print("ERROR: Should have raised ValueError") +except ValueError as e: + print(f"Correctly rejected invalid value: {e}") + +# %% +# Serialization round-trip +# ------------------------- +# +# Options can be serialized to a dictionary (and from there to JSON). + +print("\n=== Serialization ===") +d = conf.to_dict() +print(f"Serialized keys: {sorted(d.keys())[:8]}...") + +# Create a fresh config and restore from dict +conf2 = DemoOptions() +conf2.from_dict(d) +print(f"Restored iterations: {conf2.iterations.get()}") +print(f"Restored algorithm: {conf2.algorithm.get()}") + +# %% +# Persisting options to a JSON file +# ------------------------------------ +# +# :meth:`~sigimax.config.SigimaXOptions.save`/ +# :meth:`~sigimax.config.SigimaXOptions.load` go one step further than +# ``to_dict()``/``from_dict()``: they read/write an actual ``options.json`` +# file. Called without arguments, they resolve a per-application directory +# under the user's config directory (the same one used by the legacy +# INI-based system, via :func:`guidata.configtools`). Here we pass an +# explicit path (a temporary directory) to keep the example self-contained. + +print("\n=== JSON file persistence ===") +with tempfile.TemporaryDirectory() as tmpdir: + json_path = osp.join(tmpdir, "options.json") + + conf.iterations.set(42) + conf.save(json_path) + print(f"Saved to {json_path}") + + conf3 = DemoOptions() + conf3.load(json_path) + print(f"Loaded iterations: {conf3.iterations.get()}") + +# Calling conf.save() / conf.load() with no argument targets the default, +# per-application user config directory instead of an explicit path. + +# %% +# Reset to defaults +# ------------------ + +print("\n=== Reset to defaults ===") +conf.iterations.set(999) +print(f"Before reset: {conf.iterations.get()}") +conf.reset_to_defaults() +print(f"After reset: {conf.iterations.get()}") + +# %% +# Listing all options +# -------------------- +# +# ``list_options()`` returns the names of all registered option fields. + +print("\n=== All options ===") +for name in sorted(conf.list_options()): + print(f" {name}") + +# %% +# Summary +# ------- +# +# SigimaX's configuration system provides: +# +# - **Typed fields**: ``TypedOptionField`` for int/float/str/bool, +# ``EnumOptionField`` for constrained choices +# - **Context managers**: ``option.context(value)`` for scoped overrides +# - **Serialization**: ``to_dict()`` / ``from_dict()`` for in-memory JSON round-trips +# - **File persistence**: ``save()`` / ``load()`` for JSON files, defaulting to a +# per-application directory under the user's config directory +# - **Validation**: Type checking and enum constraint enforcement +# - **Reset**: ``reset_to_defaults()`` to restore initial values diff --git a/doc/examples/features/h5_workspace.py b/doc/examples/features/h5_workspace.py new file mode 100644 index 0000000..8b26a99 --- /dev/null +++ b/doc/examples/features/h5_workspace.py @@ -0,0 +1,180 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +HDF5 Workspace Save/Load +========================= + +SigimaX provides the plumbing for an HDF5-backed workspace (menu actions, +file dialogs, browser) but does **not** know what a derived application's +data model looks like. This example shows how to plug your own model in by +overriding three extension points on :class:`~sigimax.mainwindow.SGMXMainWindow`: + +- :meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` — serialize your + objects when the user chooses *File > Save* +- ``load_h5_workspace`` (a convention, not a base-class method) — the + counterpart used to reload a workspace saved by your own application +- :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` — import + data coming from a *generic* (non-SigimaX) HDF5 file, as offered by + *File > Browse HDF5 file* + +See :doc:`../../user_guide/hdf5_workspace` for the full reference and +``sigimax/tests/hdf5/test_h5_derived_app.py`` for the complete test this +example is derived from. +""" + +# %% +# Importing necessary modules +# --------------------------- + +import os.path as osp +import tempfile + +import numpy as np +from guidata.io import HDF5Reader, HDF5Writer +from sigima import ImageObj, SignalObj + +from sigimax.config import CONF as Conf +from sigimax.env import execenv +from sigimax.mainwindow import SGMXMainWindow +from sigimax.utils import qthelpers as qth + +# %% +# Step 1: A minimal data model +# ------------------------------ +# +# Any object store works, as long as it can serialize/deserialize itself +# using :class:`guidata.io.HDF5Writer`/:class:`guidata.io.HDF5Reader`. Here we +# use a plain list of :class:`~sigima.objects.SignalObj`/ +# :class:`~sigima.objects.ImageObj`, grouped under two HDF5 groups. + + +class SimpleObjectStore: + """Minimal object store: two ordered lists (signals + images).""" + + def __init__(self) -> None: + self.signals: list[SignalObj] = [] + self.images: list[ImageObj] = [] + + def add_objects(self, objects: list[SignalObj | ImageObj]) -> None: + for obj in objects: + if isinstance(obj, SignalObj): + self.signals.append(obj) + elif isinstance(obj, ImageObj): + self.images.append(obj) + + @property + def count(self) -> int: + return len(self.signals) + len(self.images) + + def serialize(self, writer: HDF5Writer) -> None: + with writer.group("signals"): + for idx, sig in enumerate(self.signals): + with writer.group(f"{idx:03d}"): + sig.serialize(writer) + with writer.group("images"): + for idx, ima in enumerate(self.images): + with writer.group(f"{idx:03d}"): + ima.serialize(writer) + + def deserialize(self, reader: HDF5Reader) -> None: + self.signals.clear() + self.images.clear() + if "signals" in reader.h5: + with reader.group("signals"): + for idx in range(len(reader.h5["signals"])): + with reader.group(f"{idx:03d}"): + obj = SignalObj() + obj.deserialize(reader) + self.signals.append(obj) + if "images" in reader.h5: + with reader.group("images"): + for idx in range(len(reader.h5["images"])): + with reader.group(f"{idx:03d}"): + obj = ImageObj() + obj.deserialize(reader) + self.images.append(obj) + + +# %% +# Step 2: Override the workspace save/load hooks +# ------------------------------------------------- +# +# ``save_h5_workspace`` is the only method the base class calls (from *File > +# Save*, wired to :meth:`~sigimax.mainwindow.SGMXMainWindow.save_to_h5_file`). +# ``load_h5_workspace`` is a symmetrical helper of our own — SigimaX does not +# impose a name or signature for "load one of *our own* workspace files" +# since it depends entirely on the data model. + + +class MyAppWindow(SGMXMainWindow): + """Derived application window with a custom HDF5 workspace.""" + + def __init__(self, console: bool | None = None) -> None: + Conf.app_name.set("MyH5App") + super().__init__(console=console) + + def _before_setup(self, console: bool) -> None: + super()._before_setup(console) + self.object_store = SimpleObjectStore() + + def save_h5_workspace(self, filename: str) -> None: + filename = self._check_h5file(filename, "save") + with HDF5Writer(filename) as writer: + self.object_store.serialize(writer) + self.set_modified(False) + execenv.print(f"Workspace saved to '{filename}'") + + def load_h5_workspace(self, filename: str) -> None: + 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}'") + + +# %% +# Step 3: Round-trip +# -------------------- +# +# ``save_to_h5_file(path)``/``save_h5_workspace(path)`` and +# ``load_h5_workspace(path)`` accept an explicit path, so they never open a +# file dialog — this is what makes them usable both interactively (*File* +# menu) and headlessly (macros, tests, this example). + +with qth.sigimax_app_context(exec_loop=False): + win = MyAppWindow(console=False) + + signal = SignalObj() + signal.set_xydata(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100))) + signal.title = "Demo signal" + win.object_store.add_objects([signal]) + + with tempfile.TemporaryDirectory() as tmpdir: + path = osp.join(tmpdir, "workspace.h5") + + win.save_h5_workspace(path) + print(f"Saved {win.object_store.count} object(s) to {path}") + + reloaded = MyAppWindow(console=False) + reloaded.load_h5_workspace(path) + print(f"Reloaded {reloaded.object_store.count} object(s)") + + win.close() + +# %% +# Summary +# ------- +# +# - :meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` is the single +# contract point SigimaX relies on for *File > Save* — the base +# implementation is a documented no-op, override it in your window class +# - Add a symmetrical ``load_*`` method for reopening your own files; there is +# no base-class hook to override because the data model is entirely +# downstream +# - Override :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` +# to support importing *generic* (non-SigimaX) HDF5 files through +# *File > Browse HDF5 file* +# - To add a new node type recognized by the generic HDF5 browser itself +# (rather than importing raw datasets), subclass +# :class:`~sigimax.h5.common.BaseNode` and register it with +# ``sigimax.h5.common.NODE_FACTORY.register(MyNode)`` diff --git a/doc/examples/features/mainwindow_customization.py b/doc/examples/features/mainwindow_customization.py new file mode 100644 index 0000000..8cb15fb --- /dev/null +++ b/doc/examples/features/mainwindow_customization.py @@ -0,0 +1,141 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Main Window Customization +============================ + +:class:`~sigimax.mainwindow.SGMXMainWindow` is designed to be subclassed, not +configured. This example walks through the four extension points a derived +application typically overrides, in isolation: + +1. **Docks** — :meth:`~sigimax.mainwindow.SGMXMainWindow._setup_docks` +2. **Menu layout** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_menubar_layout` +3. **Actions** — populating menus (custom and standard) with + :func:`guidata.qthelpers.create_action`/:func:`guidata.qthelpers.add_actions` +4. **Status bar** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_extra_status_widgets` + +See :doc:`../use_cases/full_app` for a complete application built the same way. +""" + +# %% +# Importing necessary modules +# --------------------------- + +from guidata.configtools import get_icon +from guidata.qthelpers import add_actions, create_action +from plotpy.constants import PlotType +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.qthelpers import sigimax_app_context +from sigimax.widgets.plotdock import DockablePlotWidget + +# %% +# Custom main window +# --------------------- +# +# Each extension point below is independent: override only the ones your +# application needs. + + +class MyAppMainWindow(SGMXMainWindow): + """Main window demonstrating docks, menus, actions and status bar.""" + + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("CustomizedApp") + self.curve_dock = None + self.task_status = None + super().__init__(console=console, hide_on_close=hide_on_close) + + # -- 1. Docks -------------------------------------------------------- + + def _setup_docks(self): + """Add a business-specific dock (a dockable curve plot, here).""" + self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) + self._add_dockwidget(self.curve_dock, _("Signal Viewer"), name="signal_viewer") + + # -- 2. Menu layout ---------------------------------------------------- + # + # The base layout is ``[("file", "&File"), ("view", "&View"), ("help", "?")]``. + # Insert your own entries while keeping the ones the base class relies on + # (``file_menu``/``view_menu``/``help_menu`` are used internally). + + def _get_menubar_layout(self): + base_layout = super()._get_menubar_layout() + # Insert "Analysis" between "File" and "View" + return [base_layout[0], ("analysis", _("&Analysis")), *base_layout[1:]] + + # -- 3. Actions ---------------------------------------------------------- + # + # ``_post_setup`` runs once menus, docks and the console all exist, so + # it is the right place to populate both custom and standard menus. + + def _post_setup(self, console): + # Populate our own menu (created from the layout above as `self.analysis_menu`) + add_actions( + self.analysis_menu, + [ + create_action( + self, + _("Run analysis"), + icon=get_icon("libre-gui-check.svg"), + triggered=self._run_analysis, + ), + ], + ) + # Add an action to a *standard* menu created by the base class + add_actions( + self.file_menu, + [ + create_action( + self, + _("Export report..."), + triggered=self._export_report, + ), + ], + ) + + def _run_analysis(self): + self.statusBar().showMessage(_("Running analysis..."), 2000) + + def _export_report(self): + self.statusBar().showMessage(_("Exporting report..."), 2000) + + # -- 4. Status bar -------------------------------------------------------- + # + # Widgets returned here are inserted between the console status (if any) + # and the built-in memory status widget. + + def _get_extra_status_widgets(self): + self.task_status = QW.QLabel(_("Idle")) + return [self.task_status] + + +# %% +# Instantiating the window +# --------------------------- + +with sigimax_app_context(exec_loop=False): + win = MyAppMainWindow(console=False) + win.resize(900, 600) + win.show() + + print(f"Menu titles: {[a.text() for a in win.menuBar().actions()]}") + print(f"Analysis menu actions: {[a.text() for a in win.analysis_menu.actions()]}") + print(f"Extra status widgets: {win.task_status.text()}") + + win.close() + +# %% +# Summary +# ------- +# +# - ``_setup_docks`` / ``_get_menubar_layout`` / ``_get_extra_status_widgets`` +# return declarative descriptions consumed by the base class — override +# them instead of poking at Qt internals +# - ``_post_setup`` is where menus (custom or standard) get their actions, +# once everything else is guaranteed to exist +# - Standard menus (``file_menu``, ``view_menu``, ``help_menu``) remain +# available for derived applications to extend, not just replace diff --git a/doc/examples/features/plot_widget.py b/doc/examples/features/plot_widget.py new file mode 100644 index 0000000..fabeb69 --- /dev/null +++ b/doc/examples/features/plot_widget.py @@ -0,0 +1,75 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Dockable Plot Widget +==================== + +This example demonstrates how to use the +:class:`~sigimax.widgets.plotdock.DockablePlotWidget` to embed interactive PlotPy curve +and image plots in dock widgets. + +The ``DockablePlotWidget`` is a key building block for SigimaX-based applications, +providing: + +- Embedding of PlotPy ``CurvePlot`` or ``ImagePlot`` in a Qt dock widget +- Configurable dock location (left, right, top, bottom) +- Optional watermark image +- Automatic integration with the main window's dock system +""" + +# %% +# Importing necessary modules +# --------------------------- + +import numpy as np +from plotpy.builder import make +from plotpy.constants import PlotType +from qtpy import QtWidgets as QW + +from sigimax.utils.qthelpers import sigimax_app_context +from sigimax.widgets.plotdock import DockablePlotWidget + +# %% +# Creating a curve plot widget +# ----------------------------- +# +# The simplest usage: create a ``DockablePlotWidget`` with ``PlotType.CURVE`` +# and add some curves using PlotPy's builder. + +with sigimax_app_context(exec_loop=False): + # Create a main window to host the dock + main = QW.QMainWindow() + main.setWindowTitle("DockablePlotWidget Demo") + main.resize(800, 500) + + # Create a dockable curve plot + curve_widget = DockablePlotWidget(main, PlotType.CURVE) + dock, location = curve_widget.create_dockwidget("Curve Plot") + main.addDockWidget(location, dock) + + # Add some curves + x = np.linspace(0, 4 * np.pi, 500) + plot = curve_widget.get_plot() + plot.add_item(make.curve(x, np.sin(x), title="sin(x)", color="blue")) + plot.add_item(make.curve(x, np.cos(x), title="cos(x)", color="red")) + plot.do_autoscale() + + # Show the window + main.show() + + print(f"Plot type: {PlotType.CURVE}") + print(f"Dock location: {location}") + print(f"Number of items: {len(plot.get_items())}") + + main.close() + +# %% +# Summary +# ------- +# +# The ``DockablePlotWidget`` wraps PlotPy's interactive plots into dock widgets +# that integrate seamlessly with ``SGMXMainWindow`` and any ``QMainWindow``. +# +# - Use ``PlotType.CURVE`` for 1D signal display +# - Use ``PlotType.IMAGE`` for 2D image display +# - Call ``get_plot()`` to access the underlying PlotPy plot for adding items diff --git a/doc/examples/getting_started/README.txt b/doc/examples/getting_started/README.txt new file mode 100644 index 0000000..55886fc --- /dev/null +++ b/doc/examples/getting_started/README.txt @@ -0,0 +1,2 @@ +Getting Started +--------------- diff --git a/doc/examples/getting_started/minimal_app.py b/doc/examples/getting_started/minimal_app.py new file mode 100644 index 0000000..f476c57 --- /dev/null +++ b/doc/examples/getting_started/minimal_app.py @@ -0,0 +1,124 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Minimal Derived Application +============================ + +This example demonstrates the **derivation pattern** — the core concept of SigimaX. +In just a few lines of code, you can build a full-featured scientific desktop +application with menus, toolbars, console, and status bar. + +The three-step pattern is: + +1. **Subclass** :class:`~sigimax.config.SigimaXOptions` for app-specific options +2. **Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` for custom UI +3. **Call** :func:`~sigimax.app.create` to launch + +This example creates a minimal "MyApp" with a dockable curve plot widget. +""" + +# %% +# Importing necessary modules +# --------------------------- + +from plotpy.constants import PlotType + +from sigimax.app import create +from sigimax.config import CONF as Conf +from sigimax.config import EnumOptionField, SigimaXOptions, TypedOptionField, _ +from sigimax.mainwindow import SGMXMainWindow +from sigimax.utils.qthelpers import sigimax_app_context +from sigimax.widgets.plotdock import DockablePlotWidget + +# %% +# Step 1: Define custom configuration +# ------------------------------------ +# +# Subclass :class:`~sigimax.config.SigimaXOptions` to add fields specific to +# your application. Options are typed, validated, and support JSON persistence. + + +class MyAppOptions(SigimaXOptions): + """Custom configuration for the demo application.""" + + def __init__(self): + super().__init__() + self.app_name.set("MyApp") + self.app_version.set("0.1.0") + + # Add a custom string option + self.greeting = TypedOptionField( + self, + "greeting", + default="Hello from MyApp!", + expected_type=str, + description="Startup greeting message", + ) + + # Add a constrained enum option + self.theme = EnumOptionField( + self, + "theme", + default="light", + choices=["light", "dark", "auto"], + description="Application color theme", + ) + + +# %% +# Step 2: Customize the main window +# ---------------------------------- +# +# Subclass :class:`~sigimax.mainwindow.SGMXMainWindow` to add your own menus, +# toolbars, and dock widgets. + + +class MyAppMainWindow(SGMXMainWindow): + """Main window with a dockable curve viewer.""" + + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("MyApp") + Conf.app_version.set("0.1.0") + super().__init__(console=console, hide_on_close=hide_on_close) + + # Add a dockable curve plot widget + self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) + dock, loc = self.curve_dock.create_dockwidget(_("Curve Viewer")) + self.addDockWidget(loc, dock) + + +# %% +# Step 3: Launch the application +# -------------------------------- +# +# Use :func:`~sigimax.app.create` to instantiate the window (without entering +# the Qt event loop, so sphinx-gallery can capture the screenshot). + +with sigimax_app_context(exec_loop=False): + win = create( + window_class=MyAppMainWindow, + splash=False, + console=False, + size=(900, 600), + ) + win.show() + + # Print configuration to verify it works + print(f"App name: {Conf.app_name.get()}") + print(f"Window title: {win.windowTitle()}") + + win.set_modified(False) + win.close() + +# %% +# Summary +# ------- +# +# This example showed the minimal derivation pattern: +# +# - **Configuration**: ``MyAppOptions`` adds typed, validated options +# - **Main window**: ``MyAppMainWindow`` adds a curve plot dock +# - **Launcher**: ``create()`` or ``run()`` starts the application +# +# For a production app, use ``run(window_class=MyAppMainWindow)`` instead +# of ``create()`` — it enters the Qt event loop and shows a splash screen. diff --git a/doc/examples/use_cases/README.txt b/doc/examples/use_cases/README.txt new file mode 100644 index 0000000..6b0454f --- /dev/null +++ b/doc/examples/use_cases/README.txt @@ -0,0 +1,2 @@ +Use Cases +--------- diff --git a/doc/examples/use_cases/full_app.py b/doc/examples/use_cases/full_app.py new file mode 100644 index 0000000..d1bc32e --- /dev/null +++ b/doc/examples/use_cases/full_app.py @@ -0,0 +1,185 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Full Derived Application +========================= + +This use case demonstrates a complete derived application built on SigimaX, +showcasing: + +- Custom configuration with typed options +- Custom main window with menus, toolbars, and dock widgets +- Interactive curve generation using PlotPy +- Configuration display via console + +This example mirrors the derivation pattern used by +`DataLab `_ — the flagship application built +on SigimaX. +""" + +# %% +# Importing necessary modules +# --------------------------- + +import numpy as np +from guidata.configtools import get_icon +from guidata.qthelpers import add_actions, create_action +from plotpy.builder import make +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.qthelpers import sigimax_app_context +from sigimax.widgets.plotdock import DockablePlotWidget + +# %% +# Step 1: Define the application configuration +# ---------------------------------------------- +# +# Custom options extend :class:`~sigimax.config.SigimaXOptions` with +# domain-specific settings. + + +class SciAppOptions(SigimaXOptions): + """Configuration for a scientific analysis application.""" + + def __init__(self): + super().__init__() + self.app_name.set("SciApp") + self.app_version.set("1.0.0") + self.app_desc.set("Scientific analysis app built on SigimaX") + + self.sample_rate = TypedOptionField( + self, + "sample_rate", + default=1000, + expected_type=int, + description="Default sampling rate (Hz)", + ) + self.signal_type = EnumOptionField( + self, + "signal_type", + default="sine", + choices=["sine", "square", "sawtooth", "noise"], + description="Default signal type for generation", + ) + + +# %% +# Step 2: Build the custom main window +# -------------------------------------- +# +# Override :class:`~sigimax.mainwindow.SGMXMainWindow` to add domain-specific +# menus, toolbars, and dock widgets. + + +class SciAppMainWindow(SGMXMainWindow): + """Main window for the SciApp analysis application.""" + + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("SciApp") + Conf.app_version.set("1.0.0") + self.curve_dock = None + super().__init__(console=console, hide_on_close=hide_on_close) + + def _setup_docks(self): + """Add the dockable curve plot.""" + self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) + self._add_dockwidget(self.curve_dock, _("Signal Viewer"), name="signal_viewer") + + def _post_setup(self, console): + """Add custom menus and toolbar.""" + self._setup_analysis_menu() + self._setup_toolbar() + + def _setup_analysis_menu(self): + """Add a custom Analysis menu.""" + menu = self.menuBar().addMenu(_("&Analysis")) + add_actions( + menu, + [ + create_action( + self, + _("Generate signal"), + icon=get_icon("new_signal.svg"), + triggered=self._generate_signal, + ), + create_action( + self, + _("Clear plot"), + icon=get_icon("libre-gui-close.svg"), + triggered=self._clear_plot, + ), + ], + ) + + def _setup_toolbar(self): + """Add a quick-access toolbar.""" + toolbar = QW.QToolBar(_("Analysis"), self) + toolbar.setObjectName("analysis_toolbar") + self.addToolBar(QC.Qt.TopToolBarArea, toolbar) + toolbar.addAction( + create_action( + self, + _("Generate"), + icon=get_icon("new_signal.svg"), + triggered=self._generate_signal, + ) + ) + + def _generate_signal(self): + """Generate a test signal and add it to the plot.""" + t = np.linspace(0, 1, 1000) + y = np.sin(2 * np.pi * 5 * t) + 0.3 * np.random.randn(len(t)) + plot = self.curve_dock.get_plot() + plot.add_item(make.curve(t, y, title="Signal", color="blue")) + plot.do_autoscale() + self.statusBar().showMessage(_("Signal generated"), 3000) + + def _clear_plot(self): + """Clear all items from the plot.""" + plot = self.curve_dock.get_plot() + plot.del_all_items() + plot.replot() + + +# %% +# Step 3: Launch and demonstrate +# -------------------------------- + +with sigimax_app_context(exec_loop=False): + win = sigimax_create( + window_class=SciAppMainWindow, + splash=False, + console=False, + size=(1000, 650), + ) + win.show() + + # Generate a signal to demonstrate + win._generate_signal() # noqa: SLF001 + + print(f"Application: {Conf.app_name.get()}") + print(f"Window title: {win.windowTitle()}") + print(f"Plot items: {len(win.curve_dock.get_plot().get_items())}") + + win.set_modified(False) + win.close() + +# %% +# Summary +# ------- +# +# This example demonstrated a complete SigimaX-based application with: +# +# - **Custom configuration** (``SciAppOptions``) with typed fields +# - **Custom main window** (``SciAppMainWindow``) with Analysis menu and toolbar +# - **Interactive plot** via ``DockablePlotWidget`` +# - **Status bar** messages on user actions +# +# For a standalone application, replace the ``create()`` call with +# ``run(window_class=SciAppMainWindow, console=True)`` to enter the Qt event loop. diff --git a/doc/images/Sigima.svg b/doc/images/Sigima.svg new file mode 100644 index 0000000..848cd81 --- /dev/null +++ b/doc/images/Sigima.svg @@ -0,0 +1,135 @@ + + + +image/svg+xmlS diff --git a/doc/images/SigimaX-Banner.png b/doc/images/SigimaX-Banner.png new file mode 100644 index 0000000..107b84d Binary files /dev/null and b/doc/images/SigimaX-Banner.png differ diff --git a/doc/images/SigimaX-Banner.svg b/doc/images/SigimaX-Banner.svg new file mode 100644 index 0000000..b4c96c3 --- /dev/null +++ b/doc/images/SigimaX-Banner.svg @@ -0,0 +1,135 @@ + + + +image/svg+xmlSigima diff --git a/doc/images/logos/cea.svg b/doc/images/logos/cea.svg new file mode 100644 index 0000000..7a5a31e --- /dev/null +++ b/doc/images/logos/cea.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + diff --git a/doc/images/logos/codra.svg b/doc/images/logos/codra.svg new file mode 100644 index 0000000..d45f06f --- /dev/null +++ b/doc/images/logos/codra.svg @@ -0,0 +1,65 @@ + + diff --git a/doc/images/logos/nlnet.svg b/doc/images/logos/nlnet.svg new file mode 100644 index 0000000..d887c83 --- /dev/null +++ b/doc/images/logos/nlnet.svg @@ -0,0 +1,41 @@ + + diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..416537e --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,116 @@ +SigimaX +======= + +**SigimaX** is an open-source Python framework for building Qt-based scientific +desktop applications. It provides a reusable application skeleton — main window, +configuration system, embedded widgets, and HDF5 infrastructure — so that +developers can focus on domain-specific features. + +.. figure:: _static/DataLab-Banner.svg + :align: center + :width: 300 px + :class: dark-light no-scaled-link + + Developed and maintained by the DataLab Platform Developers, **SigimaX** powers the GUI layer of `DataLab `_. + + +.. only:: html and not latex + + .. grid:: 2 2 4 4 + :gutter: 1 2 3 4 + + .. grid-item-card:: :octicon:`rocket;1em;sd-text-info` User Guide + :link: user_guide/index + :link-type: doc + + Installation, overview, and features + + .. grid-item-card:: :octicon:`code;1em;sd-text-info` Examples + :link: ../auto_examples/index + :link-type: doc + + Gallery of examples + + .. grid-item-card:: :octicon:`book;1em;sd-text-info` API + :link: api/index + :link-type: doc + + Reference documentation + + .. grid-item-card:: :octicon:`gear;1em;sd-text-info` Contributing + :link: contributing/index + :link-type: doc + + Getting involved in the project + + +Quick Start +----------- + +Build a scientific desktop application in three steps: + +.. code-block:: python + + from sigimax.app import run + from sigimax.config import CONF as Conf, SigimaXOptions, _ + from sigimax.mainwindow import SGMXMainWindow + from sigimax.widgets.plotdock import DockablePlotWidget + from sigimax.config import TypedOptionField + from plotpy.constants import PlotType + + # 1. Custom configuration + class MyAppOptions(SigimaXOptions): + def __init__(self): + super().__init__() + self.app_name.set("MyApp") + + # 2. Custom main window + class MyAppMainWindow(SGMXMainWindow): + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("MyApp") + super().__init__(console=console, hide_on_close=hide_on_close) + dock_widget = DockablePlotWidget(self, PlotType.CURVE) + dock, loc = dock_widget.create_dockwidget(_("Plot")) + self.addDockWidget(loc, dock) + + # 3. Launch + run(window_class=MyAppMainWindow) + + +SigimaX has been funded by the following stakeholders: + +.. list-table:: + :header-rows: 0 + + * - |cea_logo| + - `CEA `_, the French Alternative Energies and Atomic Energy Commission, is the major investor in DataLab, and is the main contributor to the project. + + * - |codra_logo| + - `CODRA`_, a software engineering and editor firm, has supported DataLab open-source journey since its inception (see `here `_). + +.. |cea_logo| image:: images/logos/cea.svg + :width: 64px + :height: 64px + :target: https://www.cea.fr + :class: dark-light no-scaled-link + +.. |codra_logo| image:: images/logos/codra.svg + :width: 64px + :height: 64px + :target: https://codra.net + :class: dark-light no-scaled-link + +.. toctree:: + :maxdepth: 2 + :caption: Contents + :hidden: + + user_guide/index + auto_examples/index + api/index + contributing/index + requirements + release_notes/index + +.. _DataLab: https://www.datalab-platform.com +.. _CODRA: https://codra.net/ diff --git a/doc/locale/fr/LC_MESSAGES/api/index.po b/doc/locale/fr/LC_MESSAGES/api/index.po new file mode 100644 index 0000000..30ccc3b --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/api/index.po @@ -0,0 +1,76 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Public modules:" +msgstr "Modules publics :" + +msgid "API" +msgstr "API" + +msgid "The public Application Programming Interface (API) of SigimaX provides the building blocks for creating scientific desktop applications." +msgstr "L'interface de programmation d'application (API) publique de SigimaX fournit les briques de base pour créer des applications de bureau scientifiques." + +msgid "Module" +msgstr "Module" + +msgid "Purpose" +msgstr "Objectif" + +msgid ":mod:`sigimax.app`" +msgstr ":mod:`sigimax.app`" + +msgid "Application launcher — ``create()`` and ``run()`` entry points for starting a SigimaX-based application with splash screen support." +msgstr "Lanceur d'application — points d'entrée ``create()`` et ``run()`` pour démarrer une application basée sur SigimaX avec prise en charge de l'écran de démarrage." + +msgid ":mod:`sigimax.config`" +msgstr ":mod:`sigimax.config`" + +msgid "Configuration system — ``SigimaXOptions`` singleton (``CONF``), typed option fields (``EnumOptionField``, ``TupleOptionField``, ``FontOptionField``), and translation function ``_()``." +msgstr "Système de configuration — singleton ``SigimaXOptions`` (``CONF``), champs d'options typés (``EnumOptionField``, ``TupleOptionField``, ``FontOptionField``), et fonction de traduction ``_()``." + +msgid ":mod:`sigimax.env`" +msgstr ":mod:`sigimax.env`" + +msgid "Runtime environment — ``SGMXExecEnv`` singleton (``execenv``) for controlling unattended mode, verbosity, demo mode, and screenshots." +msgstr "Environnement d'exécution — singleton ``SGMXExecEnv`` (``execenv``) pour contrôler le mode non interactif, la verbosité, le mode démo et les captures d'écran." + +msgid ":mod:`sigimax.mainwindow`" +msgstr ":mod:`sigimax.mainwindow`" + +msgid "Generic main window — ``SGMXMainWindow`` with customizable menus, toolbars, console, HDF5 workspace, and status bar." +msgstr "Fenêtre principale générique — ``SGMXMainWindow`` avec menus, barres d'outils, console, espace de travail HDF5 et barre d'état personnalisables." + +msgid ":mod:`sigimax.widgets`" +msgstr ":mod:`sigimax.widgets`" + +msgid "Reusable Qt widgets — scientific dialogs (fitting, peak detection, baseline, cursor, delta-X), HDF5 browser, log viewer, wizard, splash screen, status bar, and dockable plot widgets." +msgstr "Widgets Qt réutilisables — boîtes de dialogue scientifiques (ajustement, détection de pics, ligne de base, curseur, delta-X), navigateur HDF5, visionneuse de journaux, assistant, écran de démarrage, barre d'état et widgets graphiques ancrables." + +msgid ":mod:`sigimax.h5`" +msgstr ":mod:`sigimax.h5`" + +msgid "HDF5 I/O — import, read, write, and browse HDF5 files with node factory and data extraction utilities." +msgstr "E/S HDF5 — import, lecture, écriture et navigation dans les fichiers HDF5 avec utilitaires de fabrique de nœuds et d'extraction de données." + +msgid ":mod:`sigimax.adapters_plotpy`" +msgstr ":mod:`sigimax.adapters_plotpy`" + +msgid "PlotPy adapters — converters between Sigima objects (``SignalObj``, ``ImageObj``, ROIs) and PlotPy plot/annotation items." +msgstr "Adaptateurs PlotPy — convertisseurs entre les objets Sigima (``SignalObj``, ``ImageObj``, ROI) et les éléments de tracé/annotation PlotPy." + +msgid ":mod:`sigimax.utils`" +msgstr ":mod:`sigimax.utils`" + +msgid "Utilities — Qt helpers (log management, signal blocking, progress bars), configuration directory resolution, and callback workers." +msgstr "Utilitaires — aides Qt (gestion des journaux, blocage de signaux, barres de progression), résolution du répertoire de configuration et workers de rappel." diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/features/adapters_plotpy_factory.po b/doc/locale/fr/LC_MESSAGES/auto_examples/features/adapters_plotpy_factory.po new file mode 100644 index 0000000..372049c --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/features/adapters_plotpy_factory.po @@ -0,0 +1,73 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "PlotPy Adapter Factory" +msgstr "Fabrique d'adaptateurs PlotPy" + +msgid "SigimaX converts Sigima objects (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`, ROIs) to/from PlotPy plot items through a small set of adapter classes. Which adapter class is used for a given object is resolved by :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` — a single, overridable indirection point." +msgstr "SigimaX convertit les objets Sigima (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`, ROI) vers/depuis des items PlotPy via un petit ensemble de classes d'adaptateurs. La classe d'adaptateur utilisée pour un objet donné est résolue par :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` — un unique point d'indirection surchargeable." + +msgid "This is useful when a derived application:" +msgstr "Ceci est utile lorsqu'une application dérivée :" + +msgid "adds a **new object type** that needs its own PlotPy rendering, or" +msgstr "ajoute un **nouveau type d'objet** qui nécessite son propre rendu PlotPy, ou" + +msgid "wants to **substitute** one of SigimaX's built-in adapters (e.g. to draw images with a custom colormap policy) without touching SigimaX itself." +msgstr "souhaite **remplacer** l'un des adaptateurs intégrés de SigimaX (par exemple pour dessiner des images avec une politique de palette de couleurs personnalisée) sans toucher à SigimaX lui-même." + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Default resolution" +msgstr "Résolution par défaut" + +msgid ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` asks the *currently active* factory (:func:`~sigimax.adapters_plotpy.factories.get_adapter_factory`) for the right adapter class, then instantiates it. Out of the box, this is a :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` that dispatches on the Sigima object type." +msgstr ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` demande à la factory *actuellement active* (:func:`~sigimax.adapters_plotpy.factories.get_adapter_factory`) la bonne classe d'adaptateur, puis l'instancie. Par défaut, il s'agit d'une :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` qui répartit selon le type d'objet Sigima." + +msgid "Overriding the factory" +msgstr "Surcharger la factory" + +msgid "Subclass :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` and override :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class`, delegating to ``super()`` for the types you don't need to change. Install it with :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` so that every SigimaX component (dock widgets, HDF5 browser preview, ROI editing) picks it up transparently." +msgstr "Sous-classez :class:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory` et surchargez :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class`, en déléguant à ``super()`` pour les types que vous n'avez pas besoin de modifier. Installez-la avec :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` afin que chaque composant SigimaX (widgets ancrables, aperçu du navigateur HDF5, édition de ROI) l'utilise de façon transparente." + +msgid "Summary" +msgstr "Résumé" + +msgid ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` is the single entry point application code should use to go from a Sigima object to a PlotPy adapter" +msgstr ":func:`~sigimax.adapters_plotpy.create_adapter_from_object` est l'unique point d'entrée que le code applicatif doit utiliser pour passer d'un objet Sigima à un adaptateur PlotPy" + +msgid ":func:`~sigimax.adapters_plotpy.factories.get_adapter_factory` / :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` let a derived application install its own factory once, globally" +msgstr ":func:`~sigimax.adapters_plotpy.factories.get_adapter_factory` / :func:`~sigimax.adapters_plotpy.factories.set_adapter_factory` permettent à une application dérivée d'installer sa propre factory une fois, globalement" + +msgid "Override :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class` for object-to-item resolution and ``get_adapter_class_for_plot_item()`` for the reverse (item-to-ROI) direction" +msgstr "Surchargez :meth:`~sigimax.adapters_plotpy.factories.PlotPyAdapterFactory.get_adapter_class` pour la résolution objet-vers-item et ``get_adapter_class_for_plot_item()`` pour le sens inverse (item-vers-ROI)" + +msgid "Call :func:`~sigimax.adapters_plotpy.factories.reset_adapter_factory` to restore the SigimaX base factory (mostly useful in tests)" +msgstr "Appelez :func:`~sigimax.adapters_plotpy.factories.reset_adapter_factory` pour restaurer la factory de base de SigimaX (surtout utile dans les tests)" + +msgid ":download:`Download Jupyter notebook: adapters_plotpy_factory.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : adapters_plotpy_factory.ipynb `" + +msgid ":download:`Download Python source code: adapters_plotpy_factory.py `" +msgstr ":download:`Télécharger le code source Python : adapters_plotpy_factory.py `" + +msgid ":download:`Download zipped: adapters_plotpy_factory.zip `" +msgstr ":download:`Télécharger l'archive : adapters_plotpy_factory.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/features/configuration.po b/doc/locale/fr/LC_MESSAGES/auto_examples/features/configuration.po new file mode 100644 index 0000000..7dd3936 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/features/configuration.po @@ -0,0 +1,118 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "Configuration System" +msgstr "Système de configuration" + +msgid "This example demonstrates SigimaX's configuration system — typed option fields with ``get()``/``set()``/``context()`` API, JSON persistence, and validation." +msgstr "Cet exemple illustre le système de configuration de SigimaX — des champs d'options typés avec l'API ``get()``/``set()``/``context()``, la persistance JSON et la validation." + +msgid "The configuration system is the backbone of any SigimaX-based application. It provides:" +msgstr "Le système de configuration est l'épine dorsale de toute application basée sur SigimaX. Il fournit :" + +msgid "**Type safety**: Options are validated on set" +msgstr "**Sûreté de typage** : les options sont validées à l'affectation" + +msgid "**Context managers**: Temporary overrides that auto-restore" +msgstr "**Gestionnaires de contexte** : surcharges temporaires avec restauration automatique" + +msgid "**Serialization**: JSON round-trip for persistence" +msgstr "**Sérialisation** : aller-retour JSON pour la persistance" + +msgid "**Enum constraints**: Options restricted to specific choices" +msgstr "**Contraintes d'énumération** : options restreintes à des choix spécifiques" + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Creating a custom configuration" +msgstr "Création d'une configuration personnalisée" + +msgid "Subclass :class:`~sigimax.config.SigimaXOptions` and add typed fields." +msgstr "Sous-classer :class:`~sigimax.config.SigimaXOptions` et ajouter des champs typés." + +msgid "Basic get/set operations" +msgstr "Opérations get/set de base" + +msgid "Context manager for temporary overrides" +msgstr "Gestionnaire de contexte pour surcharges temporaires" + +msgid "The ``context()`` method temporarily overrides an option and automatically restores the previous value when leaving the block." +msgstr "La méthode ``context()`` surcharge temporairement une option et restaure automatiquement la valeur précédente en sortant du bloc." + +msgid "Enum validation" +msgstr "Validation des énumérations" + +msgid "``EnumOptionField`` rejects values not in the allowed choices." +msgstr "``EnumOptionField`` rejette les valeurs qui ne font pas partie des choix autorisés." + +msgid "Serialization round-trip" +msgstr "Aller-retour de sérialisation" + +msgid "Options can be serialized to a dictionary (and from there to JSON)." +msgstr "Les options peuvent être sérialisées en dictionnaire (puis en JSON)." + +msgid "Persisting options to a JSON file" +msgstr "Persister les options dans un fichier JSON" + +msgid ":meth:`~sigimax.config.SigimaXOptions.save`/ :meth:`~sigimax.config.SigimaXOptions.load` go one step further than ``to_dict()``/``from_dict()``: they read/write an actual ``options.json`` file. Called without arguments, they resolve a per-application directory under the user's config directory (the same one used by the legacy INI-based system, via :func:`guidata.configtools`). Here we pass an explicit path (a temporary directory) to keep the example self-contained." +msgstr ":meth:`~sigimax.config.SigimaXOptions.save`/ :meth:`~sigimax.config.SigimaXOptions.load` vont plus loin que ``to_dict()``/``from_dict()`` : elles lisent/écrivent un véritable fichier ``options.json``. Appelées sans argument, elles résolvent un répertoire propre à l'application sous le répertoire de configuration de l'utilisateur (le même que celui utilisé par l'ancien système basé sur INI, via :func:`guidata.configtools`). Ici, nous passons un chemin explicite (un répertoire temporaire) pour garder l'exemple autonome." + +msgid "Reset to defaults" +msgstr "Réinitialisation aux valeurs par défaut" + +msgid "Listing all options" +msgstr "Lister toutes les options" + +msgid "``list_options()`` returns the names of all registered option fields." +msgstr "``list_options()`` retourne les noms de tous les champs d'options enregistrés." + +msgid "Summary" +msgstr "Résumé" + +msgid "SigimaX's configuration system provides:" +msgstr "Le système de configuration de SigimaX fournit :" + +msgid "**Typed fields**: ``TypedOptionField`` for int/float/str/bool, ``EnumOptionField`` for constrained choices" +msgstr "**Champs typés** : ``TypedOptionField`` pour int/float/str/bool, ``EnumOptionField`` pour les choix contraints" + +msgid "**Context managers**: ``option.context(value)`` for scoped overrides" +msgstr "**Gestionnaires de contexte** : ``option.context(value)`` pour des surcharges avec portée limitée" + +msgid "**Serialization**: ``to_dict()`` / ``from_dict()`` for in-memory JSON round-trips" +msgstr "**Sérialisation** : ``to_dict()`` / ``from_dict()`` pour des allers-retours JSON en mémoire" + +msgid "**File persistence**: ``save()`` / ``load()`` for JSON files, defaulting to a per-application directory under the user's config directory" +msgstr "**Persistance fichier** : ``save()`` / ``load()`` pour des fichiers JSON, avec par défaut un répertoire propre à l'application sous le répertoire de configuration de l'utilisateur" + +msgid "**Validation**: Type checking and enum constraint enforcement" +msgstr "**Validation** : vérification de type et application des contraintes d'énumération" + +msgid "**Reset**: ``reset_to_defaults()`` to restore initial values" +msgstr "**Réinitialisation** : ``reset_to_defaults()`` pour restaurer les valeurs initiales" + +msgid ":download:`Download Jupyter notebook: configuration.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : configuration.ipynb `" + +msgid ":download:`Download Python source code: configuration.py `" +msgstr ":download:`Télécharger le code source Python : configuration.py `" + +msgid ":download:`Download zipped: configuration.zip `" +msgstr ":download:`Télécharger l'archive : configuration.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/features/h5_workspace.po b/doc/locale/fr/LC_MESSAGES/auto_examples/features/h5_workspace.po new file mode 100644 index 0000000..95c4f7d --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/features/h5_workspace.po @@ -0,0 +1,82 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "HDF5 Workspace Save/Load" +msgstr "Sauvegarde et chargement de l'espace de travail HDF5" + +msgid "SigimaX provides the plumbing for an HDF5-backed workspace (menu actions, file dialogs, browser) but does **not** know what a derived application's data model looks like. This example shows how to plug your own model in by overriding three extension points on :class:`~sigimax.mainwindow.SGMXMainWindow`:" +msgstr "SigimaX fournit la plomberie d'un espace de travail basé sur HDF5 (actions de menu, boîtes de dialogue de fichiers, navigateur) mais ne connaît **pas** à quoi ressemble le modèle de données d'une application dérivée. Cet exemple montre comment y brancher votre propre modèle en surchargeant trois points d'extension de :class:`~sigimax.mainwindow.SGMXMainWindow` :" + +msgid ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` — serialize your objects when the user chooses *File > Save*" +msgstr ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` — sérialise vos objets lorsque l'utilisateur choisit *Fichier > Enregistrer*" + +msgid "``load_h5_workspace`` (a convention, not a base-class method) — the counterpart used to reload a workspace saved by your own application" +msgstr "``load_h5_workspace`` (une convention, pas une méthode de la classe de base) — le pendant utilisé pour recharger un espace de travail enregistré par votre propre application" + +msgid ":meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` — import data coming from a *generic* (non-SigimaX) HDF5 file, as offered by *File > Browse HDF5 file*" +msgstr ":meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` — importe des données provenant d'un fichier HDF5 *générique* (non-SigimaX), comme proposé par *Fichier > Parcourir un fichier HDF5*" + +msgid "See :doc:`../../user_guide/hdf5_workspace` for the full reference and ``sigimax/tests/hdf5/test_h5_derived_app.py`` for the complete test this example is derived from." +msgstr "Voir :doc:`../../user_guide/hdf5_workspace` pour la référence complète et ``sigimax/tests/hdf5/test_h5_derived_app.py`` pour le test complet dont cet exemple est issu." + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Step 1: A minimal data model" +msgstr "Étape 1 : Un modèle de données minimal" + +msgid "Any object store works, as long as it can serialize/deserialize itself using :class:`guidata.io.HDF5Writer`/:class:`guidata.io.HDF5Reader`. Here we use a plain list of :class:`~sigima.objects.SignalObj`/ :class:`~sigima.objects.ImageObj`, grouped under two HDF5 groups." +msgstr "N'importe quel magasin d'objets convient, tant qu'il peut se sérialiser/désérialiser lui-même avec :class:`guidata.io.HDF5Writer`/:class:`guidata.io.HDF5Reader`. Ici, nous utilisons une simple liste de :class:`~sigima.objects.SignalObj`/ :class:`~sigima.objects.ImageObj`, regroupée sous deux groupes HDF5." + +msgid "Step 2: Override the workspace save/load hooks" +msgstr "Étape 2 : Surcharger les points d'entrée de sauvegarde/chargement de l'espace de travail" + +msgid "``save_h5_workspace`` is the only method the base class calls (from *File > Save*, wired to :meth:`~sigimax.mainwindow.SGMXMainWindow.save_to_h5_file`). ``load_h5_workspace`` is a symmetrical helper of our own — SigimaX does not impose a name or signature for \"load one of *our own* workspace files\" since it depends entirely on the data model." +msgstr "``save_h5_workspace`` est la seule méthode que la classe de base appelle (depuis *Fichier > Enregistrer*, reliée à :meth:`~sigimax.mainwindow.SGMXMainWindow.save_to_h5_file`). ``load_h5_workspace`` est un helper symétrique qui nous est propre — SigimaX n'impose ni nom ni signature pour « charger un de *nos propres* fichiers d'espace de travail », car cela dépend entièrement du modèle de données." + +msgid "Step 3: Round-trip" +msgstr "Étape 3 : Aller-retour" + +msgid "``save_to_h5_file(path)``/``save_h5_workspace(path)`` and ``load_h5_workspace(path)`` accept an explicit path, so they never open a file dialog — this is what makes them usable both interactively (*File* menu) and headlessly (macros, tests, this example)." +msgstr "``save_to_h5_file(path)``/``save_h5_workspace(path)`` et ``load_h5_workspace(path)`` acceptent un chemin explicite, si bien qu'elles n'ouvrent jamais de boîte de dialogue — c'est ce qui les rend utilisables à la fois interactivement (menu *Fichier*) et sans interface (macros, tests, cet exemple)." + +msgid "Summary" +msgstr "Résumé" + +msgid ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` is the single contract point SigimaX relies on for *File > Save* — the base implementation is a documented no-op, override it in your window class" +msgstr ":meth:`~sigimax.mainwindow.SGMXMainWindow.save_h5_workspace` est l'unique point de contrat sur lequel SigimaX s'appuie pour *Fichier > Enregistrer* — l'implémentation de base est un no-op documenté, à surcharger dans votre classe de fenêtre" + +msgid "Add a symmetrical ``load_*`` method for reopening your own files; there is no base-class hook to override because the data model is entirely downstream" +msgstr "Ajoutez une méthode symétrique ``load_*`` pour rouvrir vos propres fichiers ; il n'existe pas de point d'extension de la classe de base à surcharger car le modèle de données est entièrement en aval" + +msgid "Override :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` to support importing *generic* (non-SigimaX) HDF5 files through *File > Browse HDF5 file*" +msgstr "Surchargez :meth:`~sigimax.mainwindow.SGMXMainWindow.import_dataset_from_file` pour prendre en charge l'import de fichiers HDF5 *génériques* (non-SigimaX) via *Fichier > Parcourir un fichier HDF5*" + +msgid "To add a new node type recognized by the generic HDF5 browser itself (rather than importing raw datasets), subclass :class:`~sigimax.h5.common.BaseNode` and register it with ``sigimax.h5.common.NODE_FACTORY.register(MyNode)``" +msgstr "Pour ajouter un nouveau type de nœud reconnu par le navigateur HDF5 générique lui-même (plutôt que d'importer des jeux de données bruts), sous-classez :class:`~sigimax.h5.common.BaseNode` et enregistrez-le avec ``sigimax.h5.common.NODE_FACTORY.register(MyNode)``" + +msgid ":download:`Download Jupyter notebook: h5_workspace.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : h5_workspace.ipynb `" + +msgid ":download:`Download Python source code: h5_workspace.py `" +msgstr ":download:`Télécharger le code source Python : h5_workspace.py `" + +msgid ":download:`Download zipped: h5_workspace.zip `" +msgstr ":download:`Télécharger l'archive : h5_workspace.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/features/index.po b/doc/locale/fr/LC_MESSAGES/auto_examples/features/index.po new file mode 100644 index 0000000..92c3fc2 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/features/index.po @@ -0,0 +1,31 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Various Features" +msgstr "Fonctionnalités diverses" + +msgid ":doc:`/auto_examples/features/configuration`" +msgstr ":doc:`/auto_examples/features/configuration`" + +msgid ":doc:`/auto_examples/features/plot_widget`" +msgstr ":doc:`/auto_examples/features/plot_widget`" + +msgid ":doc:`/auto_examples/features/h5_workspace`" +msgstr ":doc:`/auto_examples/features/h5_workspace`" + +msgid ":doc:`/auto_examples/features/mainwindow_customization`" +msgstr ":doc:`/auto_examples/features/mainwindow_customization`" + +msgid ":doc:`/auto_examples/features/adapters_plotpy_factory`" +msgstr ":doc:`/auto_examples/features/adapters_plotpy_factory`" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/features/mainwindow_customization.po b/doc/locale/fr/LC_MESSAGES/auto_examples/features/mainwindow_customization.po new file mode 100644 index 0000000..a306028 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/features/mainwindow_customization.po @@ -0,0 +1,73 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "Main Window Customization" +msgstr "Personnalisation de la fenêtre principale" + +msgid ":class:`~sigimax.mainwindow.SGMXMainWindow` is designed to be subclassed, not configured. This example walks through the four extension points a derived application typically overrides, in isolation:" +msgstr ":class:`~sigimax.mainwindow.SGMXMainWindow` est conçue pour être sous-classée, pas configurée. Cet exemple parcourt, isolément, les quatre points d'extension qu'une application dérivée surcharge généralement :" + +msgid "**Docks** — :meth:`~sigimax.mainwindow.SGMXMainWindow._setup_docks`" +msgstr "**Docks** — :meth:`~sigimax.mainwindow.SGMXMainWindow._setup_docks`" + +msgid "**Menu layout** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_menubar_layout`" +msgstr "**Disposition des menus** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_menubar_layout`" + +msgid "**Actions** — populating menus (custom and standard) with :func:`guidata.qthelpers.create_action`/:func:`guidata.qthelpers.add_actions`" +msgstr "**Actions** — remplir les menus (personnalisés et standards) avec :func:`guidata.qthelpers.create_action`/:func:`guidata.qthelpers.add_actions`" + +msgid "**Status bar** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_extra_status_widgets`" +msgstr "**Barre d'état** — :meth:`~sigimax.mainwindow.SGMXMainWindow._get_extra_status_widgets`" + +msgid "See :doc:`../use_cases/full_app` for a complete application built the same way." +msgstr "Voir :doc:`../use_cases/full_app` pour une application complète construite de la même manière." + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Custom main window" +msgstr "Fenêtre principale personnalisée" + +msgid "Each extension point below is independent: override only the ones your application needs." +msgstr "Chaque point d'extension ci-dessous est indépendant : ne surchargez que ceux dont votre application a besoin." + +msgid "Instantiating the window" +msgstr "Instanciation de la fenêtre" + +msgid "Summary" +msgstr "Résumé" + +msgid "``_setup_docks`` / ``_get_menubar_layout`` / ``_get_extra_status_widgets`` return declarative descriptions consumed by the base class — override them instead of poking at Qt internals" +msgstr "``_setup_docks`` / ``_get_menubar_layout`` / ``_get_extra_status_widgets`` renvoient des descriptions déclaratives consommées par la classe de base — surchargez-les plutôt que de manipuler les rouages internes de Qt" + +msgid "``_post_setup`` is where menus (custom or standard) get their actions, once everything else is guaranteed to exist" +msgstr "``_post_setup`` est l'endroit où les menus (personnalisés ou standards) reçoivent leurs actions, une fois que tout le reste est garanti d'exister" + +msgid "Standard menus (``file_menu``, ``view_menu``, ``help_menu``) remain available for derived applications to extend, not just replace" +msgstr "Les menus standards (``file_menu``, ``view_menu``, ``help_menu``) restent disponibles pour que les applications dérivées les étendent, et pas seulement les remplacent" + +msgid ":download:`Download Jupyter notebook: mainwindow_customization.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : mainwindow_customization.ipynb `" + +msgid ":download:`Download Python source code: mainwindow_customization.py `" +msgstr ":download:`Télécharger le code source Python : mainwindow_customization.py `" + +msgid ":download:`Download zipped: mainwindow_customization.zip `" +msgstr ":download:`Télécharger l'archive : mainwindow_customization.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/features/plot_widget.po b/doc/locale/fr/LC_MESSAGES/auto_examples/features/plot_widget.po new file mode 100644 index 0000000..74261df --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/features/plot_widget.po @@ -0,0 +1,73 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "Dockable Plot Widget" +msgstr "Widget de graphique ancrable" + +msgid "This example demonstrates how to use the :class:`~sigimax.widgets.plotdock.DockablePlotWidget` to embed interactive PlotPy curve and image plots in dock widgets." +msgstr "Cet exemple montre comment utiliser le :class:`~sigimax.widgets.plotdock.DockablePlotWidget` pour intégrer des graphiques de courbes et d'images PlotPy interactifs dans des widgets ancrables." + +msgid "The ``DockablePlotWidget`` is a key building block for SigimaX-based applications, providing:" +msgstr "Le ``DockablePlotWidget`` est un élément clé pour les applications basées sur SigimaX, fournissant :" + +msgid "Embedding of PlotPy ``CurvePlot`` or ``ImagePlot`` in a Qt dock widget" +msgstr "Intégration de ``CurvePlot`` ou ``ImagePlot`` de PlotPy dans un widget dock Qt" + +msgid "Configurable dock location (left, right, top, bottom)" +msgstr "Emplacement d'ancrage configurable (gauche, droite, haut, bas)" + +msgid "Optional watermark image" +msgstr "Image de filigrane optionnelle" + +msgid "Automatic integration with the main window's dock system" +msgstr "Intégration automatique avec le système d'ancrage de la fenêtre principale" + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Creating a curve plot widget" +msgstr "Création d'un widget de graphique de courbes" + +msgid "The simplest usage: create a ``DockablePlotWidget`` with ``PlotType.CURVE`` and add some curves using PlotPy's builder." +msgstr "L'utilisation la plus simple : créer un ``DockablePlotWidget`` avec ``PlotType.CURVE`` et ajouter des courbes en utilisant le builder de PlotPy." + +msgid "Summary" +msgstr "Résumé" + +msgid "The ``DockablePlotWidget`` wraps PlotPy's interactive plots into dock widgets that integrate seamlessly with ``SGMXMainWindow`` and any ``QMainWindow``." +msgstr "Le ``DockablePlotWidget`` encapsule les graphiques interactifs de PlotPy dans des widgets ancrables qui s'intègrent de manière transparente avec ``SGMXMainWindow`` et tout ``QMainWindow``." + +msgid "Use ``PlotType.CURVE`` for 1D signal display" +msgstr "Utiliser ``PlotType.CURVE`` pour l'affichage de signaux 1D" + +msgid "Use ``PlotType.IMAGE`` for 2D image display" +msgstr "Utiliser ``PlotType.IMAGE`` pour l'affichage d'images 2D" + +msgid "Call ``get_plot()`` to access the underlying PlotPy plot for adding items" +msgstr "Appeler ``get_plot()`` pour accéder au graphique PlotPy sous-jacent et y ajouter des éléments" + +msgid ":download:`Download Jupyter notebook: plot_widget.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : plot_widget.ipynb `" + +msgid ":download:`Download Python source code: plot_widget.py `" +msgstr ":download:`Télécharger le code source Python : plot_widget.py `" + +msgid ":download:`Download zipped: plot_widget.zip `" +msgstr ":download:`Télécharger l'archive : plot_widget.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/index.po b/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/index.po new file mode 100644 index 0000000..863423a --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/index.po @@ -0,0 +1,19 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Getting Started" +msgstr "Premiers pas" + +msgid ":doc:`/auto_examples/getting_started/minimal_app`" +msgstr ":doc:`/auto_examples/getting_started/minimal_app`" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/minimal_app.po b/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/minimal_app.po new file mode 100644 index 0000000..63562b7 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/getting_started/minimal_app.po @@ -0,0 +1,88 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "Minimal Derived Application" +msgstr "Application dérivée minimale" + +msgid "This example demonstrates the **derivation pattern** — the core concept of SigimaX. In just a few lines of code, you can build a full-featured scientific desktop application with menus, toolbars, console, and status bar." +msgstr "Cet exemple illustre le **patron de dérivation** — le concept central de SigimaX. En quelques lignes de code, vous pouvez construire une application scientifique de bureau complète avec menus, barres d'outils, console et barre d'état." + +msgid "The three-step pattern is:" +msgstr "Le patron en trois étapes est :" + +msgid "**Subclass** :class:`~sigimax.config.SigimaXOptions` for app-specific options" +msgstr "**Sous-classer** :class:`~sigimax.config.SigimaXOptions` pour les options spécifiques à l'application" + +msgid "**Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` for custom UI" +msgstr "**Sous-classer** :class:`~sigimax.mainwindow.SGMXMainWindow` pour l'interface personnalisée" + +msgid "**Call** :func:`~sigimax.app.create` to launch" +msgstr "**Appeler** :func:`~sigimax.app.create` pour lancer" + +msgid "This example creates a minimal \"MyApp\" with a dockable curve plot widget." +msgstr "Cet exemple crée une application minimale \"MyApp\" avec un widget de graphique de courbes ancrable." + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Step 1: Define custom configuration" +msgstr "Étape 1 : Définir la configuration personnalisée" + +msgid "Subclass :class:`~sigimax.config.SigimaXOptions` to add fields specific to your application. Options are typed, validated, and support JSON persistence." +msgstr "Sous-classer :class:`~sigimax.config.SigimaXOptions` pour ajouter des champs spécifiques à votre application. Les options sont typées, validées et prennent en charge la persistance JSON." + +msgid "Step 2: Customize the main window" +msgstr "Étape 2 : Personnaliser la fenêtre principale" + +msgid "Subclass :class:`~sigimax.mainwindow.SGMXMainWindow` to add your own menus, toolbars, and dock widgets." +msgstr "Sous-classer :class:`~sigimax.mainwindow.SGMXMainWindow` pour ajouter vos propres menus, barres d'outils et widgets ancrables." + +msgid "Step 3: Launch the application" +msgstr "Étape 3 : Lancer l'application" + +msgid "Use :func:`~sigimax.app.create` to instantiate the window (without entering the Qt event loop, so sphinx-gallery can capture the screenshot)." +msgstr "Utiliser :func:`~sigimax.app.create` pour instancier la fenêtre (sans entrer dans la boucle d'événements Qt, afin que sphinx-gallery puisse capturer la capture d'écran)." + +msgid "Summary" +msgstr "Résumé" + +msgid "This example showed the minimal derivation pattern:" +msgstr "Cet exemple a présenté le patron de dérivation minimal :" + +msgid "**Configuration**: ``MyAppOptions`` adds typed, validated options" +msgstr "**Configuration** : ``MyAppOptions`` ajoute des options typées et validées" + +msgid "**Main window**: ``MyAppMainWindow`` adds a curve plot dock" +msgstr "**Fenêtre principale** : ``MyAppMainWindow`` ajoute un dock de graphique de courbes" + +msgid "**Launcher**: ``create()`` or ``run()`` starts the application" +msgstr "**Lanceur** : ``create()`` ou ``run()`` démarre l'application" + +msgid "For a production app, use ``run(window_class=MyAppMainWindow)`` instead of ``create()`` — it enters the Qt event loop and shows a splash screen." +msgstr "Pour une application en production, utiliser ``run(window_class=MyAppMainWindow)`` au lieu de ``create()`` — cela entre dans la boucle d'événements Qt et affiche un écran d'accueil." + +msgid ":download:`Download Jupyter notebook: minimal_app.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : minimal_app.ipynb `" + +msgid ":download:`Download Python source code: minimal_app.py `" +msgstr ":download:`Télécharger le code source Python : minimal_app.py `" + +msgid ":download:`Download zipped: minimal_app.zip `" +msgstr ":download:`Télécharger l'archive : minimal_app.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/index.po b/doc/locale/fr/LC_MESSAGES/auto_examples/index.po new file mode 100644 index 0000000..896fe73 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/index.po @@ -0,0 +1,58 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Examples" +msgstr "Exemples" + +msgid "This section presents a collection of examples demonstrating the capabilities of SigimaX. Each example is a standalone Python script that showcases specific features or use cases of the SigimaX library." +msgstr "Cette section présente une collection d'exemples démontrant les capacités de SigimaX. Chaque exemple est un script Python autonome qui met en avant des fonctionnalités ou des cas d'utilisation spécifiques de la bibliothèque SigimaX." + +msgid "Of course, some of these examples may seem trivial, but they serve to illustrate how to use various functionalities of SigimaX in a clear and concise manner." +msgstr "Bien sûr, certains de ces exemples peuvent sembler triviaux, mais ils servent à illustrer comment utiliser diverses fonctionnalités de SigimaX de manière claire et concise." + +msgid "These examples are automatically generated when building the documentation, thus ensuring that they are always up-to-date with the latest version of SigimaX." +msgstr "Ces exemples sont générés automatiquement lors de la construction de la documentation, garantissant ainsi qu'ils sont toujours fonctionnels avec la dernière version de SigimaX." + +msgid "Getting Started" +msgstr "Premiers pas" + +msgid ":doc:`/auto_examples/getting_started/minimal_app`" +msgstr ":doc:`/auto_examples/getting_started/minimal_app`" + +msgid "Various Features" +msgstr "Fonctionnalités diverses" + +msgid ":doc:`/auto_examples/features/configuration`" +msgstr ":doc:`/auto_examples/features/configuration`" + +msgid ":doc:`/auto_examples/features/plot_widget`" +msgstr ":doc:`/auto_examples/features/plot_widget`" + +msgid ":doc:`/auto_examples/features/h5_workspace`" +msgstr ":doc:`/auto_examples/features/h5_workspace`" + +msgid ":doc:`/auto_examples/features/mainwindow_customization`" +msgstr ":doc:`/auto_examples/features/mainwindow_customization`" + +msgid ":doc:`/auto_examples/features/adapters_plotpy_factory`" +msgstr ":doc:`/auto_examples/features/adapters_plotpy_factory`" + +msgid "Use Cases" +msgstr "Cas d'usage" + +msgid ":doc:`/auto_examples/use_cases/full_app`" +msgstr ":doc:`/auto_examples/use_cases/full_app`" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/full_app.po b/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/full_app.po new file mode 100644 index 0000000..6548407 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/full_app.po @@ -0,0 +1,88 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid ":ref:`Go to the end ` to download the full example code." +msgstr ":ref:`Aller à la fin ` pour télécharger le code complet de l'exemple." + +msgid "Full Derived Application" +msgstr "Application dérivée complète" + +msgid "This use case demonstrates a complete derived application built on SigimaX, showcasing:" +msgstr "Ce cas d'usage présente une application dérivée complète construite sur SigimaX, illustrant :" + +msgid "Custom configuration with typed options" +msgstr "Configuration personnalisée avec des options typées" + +msgid "Custom main window with menus, toolbars, and dock widgets" +msgstr "Fenêtre principale personnalisée avec menus, barres d'outils et widgets ancrables" + +msgid "Interactive curve generation using PlotPy" +msgstr "Génération interactive de courbes avec PlotPy" + +msgid "Configuration display via console" +msgstr "Affichage de la configuration via la console" + +msgid "This example mirrors the derivation pattern used by `DataLab `_ — the flagship application built on SigimaX." +msgstr "Cet exemple reprend le patron de dérivation utilisé par `DataLab `_ — l'application phare construite sur SigimaX." + +msgid "Importing necessary modules" +msgstr "Import des modules nécessaires" + +msgid "Step 1: Define the application configuration" +msgstr "Étape 1 : Définir la configuration de l'application" + +msgid "Custom options extend :class:`~sigimax.config.SigimaXOptions` with domain-specific settings." +msgstr "Les options personnalisées étendent :class:`~sigimax.config.SigimaXOptions` avec des paramètres spécifiques au domaine." + +msgid "Step 2: Build the custom main window" +msgstr "Étape 2 : Construire la fenêtre principale personnalisée" + +msgid "Override :class:`~sigimax.mainwindow.SGMXMainWindow` to add domain-specific menus, toolbars, and dock widgets." +msgstr "Surcharger :class:`~sigimax.mainwindow.SGMXMainWindow` pour ajouter des menus, barres d'outils et widgets ancrables spécifiques au domaine." + +msgid "Step 3: Launch and demonstrate" +msgstr "Étape 3 : Lancer et démontrer" + +msgid "Summary" +msgstr "Résumé" + +msgid "This example demonstrated a complete SigimaX-based application with:" +msgstr "Cet exemple a présenté une application complète basée sur SigimaX avec :" + +msgid "**Custom configuration** (``SciAppOptions``) with typed fields" +msgstr "**Configuration personnalisée** (``SciAppOptions``) avec des champs typés" + +msgid "**Custom main window** (``SciAppMainWindow``) with Analysis menu and toolbar" +msgstr "**Fenêtre principale personnalisée** (``SciAppMainWindow``) avec menu et barre d'outils d'analyse" + +msgid "**Interactive plot** via ``DockablePlotWidget``" +msgstr "**Graphique interactif** via ``DockablePlotWidget``" + +msgid "**Status bar** messages on user actions" +msgstr "Messages dans la **barre d'état** lors des actions utilisateur" + +msgid "For a standalone application, replace the ``create()`` call with ``run(window_class=SciAppMainWindow, console=True)`` to enter the Qt event loop." +msgstr "Pour une application autonome, remplacer l'appel à ``create()`` par ``run(window_class=SciAppMainWindow, console=True)`` pour entrer dans la boucle d'événements Qt." + +msgid ":download:`Download Jupyter notebook: full_app.ipynb `" +msgstr ":download:`Télécharger le notebook Jupyter : full_app.ipynb `" + +msgid ":download:`Download Python source code: full_app.py `" +msgstr ":download:`Télécharger le code source Python : full_app.py `" + +msgid ":download:`Download zipped: full_app.zip `" +msgstr ":download:`Télécharger l'archive : full_app.zip `" + +msgid "`Gallery generated by Sphinx-Gallery `_" +msgstr "`Galerie générée par Sphinx-Gallery `_" diff --git a/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/index.po b/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/index.po new file mode 100644 index 0000000..223f4d7 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/auto_examples/use_cases/index.po @@ -0,0 +1,19 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Use Cases" +msgstr "Cas d'usage" + +msgid ":doc:`/auto_examples/use_cases/full_app`" +msgstr ":doc:`/auto_examples/use_cases/full_app`" diff --git a/doc/locale/fr/LC_MESSAGES/contributing/index.po b/doc/locale/fr/LC_MESSAGES/contributing/index.po new file mode 100644 index 0000000..498c26b --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/contributing/index.po @@ -0,0 +1,139 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Contribute to SigimaX project, the open-source GUI framework for scientific applications" +msgstr "Contribuer au projet SigimaX, le framework open-source d'interface graphique pour les applications scientifiques" + +msgid "SigimaX, contribute, open-source, scientific, GUI, framework, Qt, Python" +msgstr "SigimaX, contribuer, open-source, scientifique, interface graphique, framework, Qt, Python" + +msgid "Contributing" +msgstr "Contribuer" + +msgid "There are many ways to contribute to SigimaX, depending on how much time you have, your experience with open source projects, and your skills." +msgstr "Il existe de nombreuses façons de contribuer à SigimaX, selon le temps dont vous disposez, votre expérience avec les projets open-source et vos compétences." + +msgid "Share your ideas and experiences" +msgstr "Partagez vos idées et expériences" + +msgid ":octicon:`info;1em;sd-text-info` :bdg-success-line:`No coding required`" +msgstr ":octicon:`info;1em;sd-text-info` :bdg-success-line:`Aucun codage requis`" + +msgid "Besides the classic bug reports and feature requests, you can share your ideas and experiences for improving SigimaX. In particular, we are very interested in your feedback on the documentation and tutorials. Moreover, if you have a use case that you would like to share with the community, please let us know." +msgstr "En plus des rapports de bogues et des demandes de fonctionnalités classiques, vous pouvez partager vos idées et expériences pour améliorer SigimaX. En particulier, nous sommes très intéressés par vos retours sur la documentation et les tutoriels. De plus, si vous avez un cas d'utilisation que vous souhaitez partager avec la communauté, n'hésitez pas à nous en informer." + +msgid " Bugs" +msgstr " Bugs" + +msgid "Reporting a bug" +msgstr "Signaler une anomalie" + +msgid " Enhancements" +msgstr " Améliorations" + +msgid "Suggesting an enhancement" +msgstr "Suggérer une amélioration" + +msgid " Documentation" +msgstr " Documentation" + +msgid "Suggesting a documentation topic" +msgstr "Suggérer un sujet de documentation" + +msgid " Tutorial" +msgstr " Tutoriel" + +msgid "Suggesting a tutorial topic" +msgstr "Suggérer un sujet de tutoriel" + +msgid "Without coding, you can contribute to SigimaX project by:" +msgstr "Sans coder, vous pouvez contribuer au projet SigimaX en :" + +msgid "`Reporting a bug `_" +msgstr "`Signalant une anomalie `_" + +msgid "`Suggesting an enhancement `_" +msgstr "`Suggérant une amélioration `_" + +msgid "`Suggesting a documentation topic `_" +msgstr "`Suggérant un sujet de documentation `_" + +msgid "`Suggesting a tutorial topic `_" +msgstr "`Suggérant un sujet de tutoriel `_" + +msgid "Share your scientific/technical knowledge" +msgstr "Partagez vos connaissances scientifiques/techniques" + +msgid "Your technical or scientific knowledge is also very valuable to us. You may contribute documentation or tutorials directly. Or, if you want to write a tutorial, we will be happy to help you get started." +msgstr "Vos connaissances techniques ou scientifiques nous sont également très précieuses. Vous pouvez contribuer directement à la documentation ou aux tutoriels. Ou, si vous souhaitez rédiger un tutoriel, nous serons heureux de vous aider à démarrer." + +msgid "Writing documentation" +msgstr "Rédiger de la documentation" + +msgid "Writing a tutorial" +msgstr "Rédiger un tutoriel" + +msgid "Sharing a use case of a derived application" +msgstr "Partager un cas d'utilisation d'une application dérivée" + +msgid "Contribute code" +msgstr "Contribuer au code" + +msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`Coding (beginner)` :bdg-warning-line:`Coding (advanced)`" +msgstr ":octicon:`info;1em;sd-text-info` :bdg-info-line:`Programmation (débutant)` :bdg-warning-line:`Programmation (avancé)`" + +msgid "Even if you are not an experienced developer, you can contribute to the project by:" +msgstr "Même si vous n'êtes pas un développeur expérimenté, vous pouvez contribuer au projet en :" + +msgid "Testing new features" +msgstr "Testant les nouvelles fonctionnalités" + +msgid "Writing or improving tests" +msgstr "Écrivant ou améliorant les tests" + +msgid "Reporting and fixing bugs" +msgstr "Signalant et corrigeant les bogues" + +msgid "If you are a developer, you can contribute to the core of the project by fixing bugs or implementing new features." +msgstr "Si vous êtes développeur, vous pouvez contribuer au cœur du projet en corrigeant des bogues ou en implémentant de nouvelles fonctionnalités." + +msgid "Development setup" +msgstr "Configuration du développement" + +msgid "Clone the repository:" +msgstr "Clonez le dépôt :" + +msgid "Run the tests:" +msgstr "Exécutez les tests :" + +msgid "Format and lint:" +msgstr "Formatez et vérifiez :" + +msgid "Code conventions" +msgstr "Conventions de code" + +msgid "Use ``from __future__ import annotations`` in all modules" +msgstr "Utilisez ``from __future__ import annotations`` dans tous les modules" + +msgid "Define ``__all__`` in all public modules" +msgstr "Définissez ``__all__`` dans tous les modules publics" + +msgid "Wrap UI strings with ``_()`` for internationalization" +msgstr "Encapsulez les chaînes d'interface utilisateur avec ``_()`` pour l'internationalisation" + +msgid "Follow Google-style docstrings" +msgstr "Suivez les docstrings de style Google" + +msgid "Use ``snake_case`` for functions, ``PascalCase`` for classes" +msgstr "Utilisez ``snake_case`` pour les fonctions, ``PascalCase`` pour les classes" diff --git a/doc/locale/fr/LC_MESSAGES/index.po b/doc/locale/fr/LC_MESSAGES/index.po new file mode 100644 index 0000000..782609f --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/index.po @@ -0,0 +1,76 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Contents" +msgstr "Table des matières" + +msgid "SigimaX" +msgstr "SigimaX" + +msgid "**SigimaX** is an open-source Python framework for building Qt-based scientific desktop applications. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features." +msgstr "**SigimaX** est un framework Python open-source pour créer des applications de bureau scientifiques basées sur Qt. Il fournit un squelette d'application réutilisable — fenêtre principale, système de configuration, widgets intégrés et infrastructure HDF5 — afin que les développeurs puissent se concentrer sur les fonctionnalités spécifiques à leur domaine." + +msgid "Developed and maintained by the DataLab Platform Developers, **SigimaX** powers the GUI layer of `DataLab `_." +msgstr "Développé et maintenu par les développeurs de la plateforme DataLab, **SigimaX** alimente la couche graphique de `DataLab `_." + +msgid " User Guide" +msgstr " Manuel utilisateur" + +msgid "Installation, overview, and features" +msgstr "Installation et fonctionnalités" + +msgid " Examples" +msgstr " Exemples" + +msgid "Gallery of examples" +msgstr "Galerie d'exemples" + +msgid " API" +msgstr " API" + +msgid "Reference documentation" +msgstr "Documentation de référence" + +msgid " Contributing" +msgstr " Contribuer" + +msgid "Getting involved in the project" +msgstr "S'impliquer dans le projet" + +msgid "Quick Start" +msgstr "Démarrage rapide" + +msgid "Build a scientific desktop application in three steps:" +msgstr "Créez une application de bureau scientifique en trois étapes :" + +msgid "SigimaX has been funded by the following stakeholders:" +msgstr "SigimaX a été financé par les parties prenantes suivantes :" + +msgid "|cea_logo|" +msgstr "|cea_logo|" + +msgid "cea_logo" +msgstr "cea_logo" + +msgid "`CEA `_, the French Alternative Energies and Atomic Energy Commission, is the major investor in DataLab, and is the main contributor to the project." +msgstr "`CEA `_, le Commissariat à l'énergie atomique et aux énergies alternatives, est le principal investisseur de DataLab et le principal contributeur au projet." + +msgid "|codra_logo|" +msgstr "|codra_logo|" + +msgid "codra_logo" +msgstr "codra_logo" + +msgid "`CODRA`_, a software engineering and editor firm, has supported DataLab open-source journey since its inception (see `here `_)." +msgstr "`CODRA`_, une entreprise d'ingénierie logicielle et d'édition, a soutenu le parcours open-source de DataLab depuis ses débuts (voir `ici `_)." diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/index.po b/doc/locale/fr/LC_MESSAGES/release_notes/index.po new file mode 100644 index 0000000..34deed1 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/release_notes/index.po @@ -0,0 +1,19 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Release notes" +msgstr "Notes de version" + +msgid "This section contains the release notes for all versions of :mod:`sigimax`, documenting new features, improvements, bug fixes, and breaking changes." +msgstr "Cette section contient les notes de version pour toutes les versions de :mod:`sigimax`, documentant les nouvelles fonctionnalités, les améliorations, les corrections de bogues et les changements incompatibles." diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_0.01.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.01.po new file mode 100644 index 0000000..796ee87 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_0.01.po @@ -0,0 +1,241 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Version 0.1" +msgstr "Version 0.1" + +msgid "SigimaX Version 0.1.0 (2026-03-04)" +msgstr "SigimaX Version 0.1.0 (04/03/2026)" + +msgid "Initial development release — SigimaX is extracted from DataLab as a reusable GUI application framework for scientific computing Qt applications." +msgstr "Première version de développement — SigimaX est extrait de DataLab en tant que framework d'application GUI réutilisable pour les applications Qt de calcul scientifique." + +msgid "Highlights" +msgstr "Points forts" + +msgid "SigimaX provides the generic \"application skeleton\" that any scientific computing Qt application can build upon by subclassing its main window and configuration system. This release contains the full extraction from DataLab, including:" +msgstr "SigimaX fournit le squelette applicatif générique sur lequel toute application Qt de calcul scientifique peut s'appuyer en héritant de sa fenêtre principale et de son système de configuration. Cette version contient l'extraction complète depuis DataLab, notamment :" + +msgid "Generic main window (`SGMXMainWindow`) with customizable menus, toolbars, and console" +msgstr "Fenêtre principale générique (`SGMXMainWindow`) avec menus, barres d'outils et console personnalisables" + +msgid "Configuration system based on Sigima's typed option fields" +msgstr "Système de configuration basé sur les champs d'options typés de Sigima" + +msgid "HDF5 browsing, generic import, and workspace persistence hooks" +msgstr "Navigation HDF5, import générique et points d'extension de persistance de l'espace de travail" + +msgid "Splash screen and application launcher (`create()` / `run()`)" +msgstr "Écran de démarrage et lanceur d'application (`create()` / `run()`)" + +msgid "PlotPy adapters for signal/image plot items" +msgstr "Adaptateurs PlotPy pour les éléments de tracé signal/image" + +msgid "Reusable scientific widgets (fit dialogs, baseline, peak detection, cursor, etc.)" +msgstr "Widgets scientifiques réutilisables (dialogues d'ajustement, ligne de base, détection de pics, curseur, etc.)" + +msgid "Comprehensive test suite with 155+ tests across unit, GUI, and app categories" +msgstr "Suite de tests complète avec plus de 155 tests répartis dans les catégories unitaire, GUI et application" + +msgid "Full Sphinx documentation with API reference and gallery examples" +msgstr "Documentation Sphinx complète avec référence API et exemples de galerie" + +msgid "Complete French translation (189 strings)" +msgstr "Traduction française complète (189 chaînes)" + +msgid "Application framework" +msgstr "Framework applicatif" + +msgid "Implemented `SGMXMainWindow` — a generic main window that derived applications subclass to build their own UI, with customizable menu order, toolbar actions, and console namespace" +msgstr "Implémentation de `SGMXMainWindow` — une fenêtre principale générique dont les applications dérivées héritent pour construire leur propre interface, avec un ordre de menus, des actions de barre d'outils et un espace de noms de console personnalisables" + +msgid "Added `create()` and `run()` application launcher functions in `app.py` with configurable splash screen support (`SplashScreenConfig`)" +msgstr "Ajout des fonctions de lancement d'application `create()` et `run()` dans `app.py` avec support configurable de l'écran de démarrage (`SplashScreenConfig`)" + +msgid "Provided overridable hooks for derived apps: `reset_all()`, `_is_save_enabled()`, `_update_file_menu()`, `_update_view_menu()`, `_about()`" +msgstr "Mise à disposition de hooks redéfinissables pour les applications dérivées : `reset_all()`, `_is_save_enabled()`, `_update_file_menu()`, `_update_view_menu()`, `_about()`" + +msgid "Added generic main toolbar configuration — derived apps can redefine toolbar actions" +msgstr "Ajout d'une configuration générique de la barre d'outils principale — les applications dérivées peuvent redéfinir les actions de la barre d'outils" + +msgid "Added quit action to the file menu" +msgstr "Ajout de l'action quitter au menu fichier" + +msgid "Configuration system" +msgstr "Système de configuration" + +msgid "Implemented typed configuration options inspired by Sigima's non-INI-file config system, with `TypedOptionField`, `EnumOptionField`, `TupleOptionField`, and `FontOptionField`" +msgstr "Implémentation d'options de configuration typées inspirées du système de configuration non-INI de Sigima, avec `TypedOptionField`, `EnumOptionField`, `TupleOptionField` et `FontOptionField`" + +msgid "Added generic application metadata options (`app_name`, `app_version`, `app_logo_path`, `app_desc`, `app_docurl`, `app_homeurl`, `app_supporturl`)" +msgstr "Ajout d'options génériques de métadonnées d'application (`app_name`, `app_version`, `app_logo_path`, `app_desc`, `app_docurl`, `app_homeurl`, `app_supporturl`)" + +#, fuzzy +msgid "Configuration supports JSON persistence via `save()` / `load()`" +msgstr "La configuration supporte la persistance JSON via `save()` / `load()` et la surcharge par variables d'environnement" + +msgid "Removed `process_isolation_enabled` option (DataLab-specific — the framework only used it cosmetically; the actual mechanism stays in DataLab)" +msgstr "Suppression de l'option `process_isolation_enabled` (spécifique à DataLab — le framework ne l'utilisait que de manière cosmétique ; le mécanisme réel reste dans DataLab)" + +msgid "HDF5 support" +msgstr "Support HDF5" + +msgid "Ported generic HDF5 browsing and dataset import" +msgstr "Portage de la navigation HDF5 générique et de l'import de jeux de données" + +msgid "Added workspace persistence hooks for derived applications; SigimaX does not impose a universal workspace format or serializer" +msgstr "Ajout de points d'extension de persistance de l'espace de travail pour les applications dérivées ; SigimaX n'impose pas de format ou de sérialiseur d'espace de travail universel" + +#, fuzzy +msgid "Added `import_dataset_from_file()` hook for derived apps to handle application-specific dataset import from HDF5" +msgstr "Ajout du hook `import_dataset_from_file()` (sans opération) pour que les applications dérivées puissent gérer l'import de jeux de données spécifiques depuis HDF5" + +msgid "Ported `H5BrowserDialog` widget for interactive HDF5 file browsing" +msgstr "Portage du widget `H5BrowserDialog` pour la navigation interactive dans les fichiers HDF5" + +msgid "Widgets" +msgstr "Widgets" + +msgid "Ported scientific dialog widgets from DataLab: curve fitting (`fitdialog`), signal baseline selection, signal peak detection, signal cursor, signal delta-X measurement, image background selection" +msgstr "Portage des widgets de dialogue scientifique depuis DataLab : ajustement de courbe (`fitdialog`), sélection de ligne de base, détection de pics, curseur de signal, mesure de delta-X, sélection de fond d'image" + +msgid "Added `DockablePlotWidget` for embedding PlotPy plots in dock widgets, with configurable watermark and dock location via `SigimaXOptions`" +msgstr "Ajout de `DockablePlotWidget` pour l'intégration de tracés PlotPy dans des widgets ancrables, avec filigrane et position d'ancrage configurables via `SigimaXOptions`" + +msgid "Ported status bar widgets: `BaseStatus`, `MemoryStatus`, `ConsoleStatus`" +msgstr "Portage des widgets de barre d'état : `BaseStatus`, `MemoryStatus`, `ConsoleStatus`" + +msgid "Added `Wizard` multi-page dialog widget" +msgstr "Ajout du widget de dialogue multi-pages `Wizard`" + +msgid "Added `LogViewerWindow` for log display" +msgstr "Ajout de `LogViewerWindow` pour l'affichage des journaux" + +msgid "Added `FileViewerWidget` for read-only file viewing" +msgstr "Ajout de `FileViewerWidget` pour la consultation de fichiers en lecture seule" + +msgid "Added `WarningErrorMessageBox` for warning/error display" +msgstr "Ajout de `WarningErrorMessageBox` pour l'affichage des avertissements et erreurs" + +msgid "Added convenience re-exports in `widgets/__init__.py` with `__all__`" +msgstr "Ajout de ré-exports pratiques dans `widgets/__init__.py` avec `__all__`" + +msgid "PlotPy adapters" +msgstr "Adaptateurs PlotPy" + +msgid "Ported `adapters_plotpy` module for converting between Sigima objects (`SignalObj`, `ImageObj`) and PlotPy plot items" +msgstr "Portage du module `adapters_plotpy` pour la conversion entre les objets Sigima (`SignalObj`, `ImageObj`) et les éléments de tracé PlotPy" + +msgid "Added `iterate_metadata_shape_items()` hook on `BaseObjPlotPyAdapter` — a no-op generator that derived apps (DataLab) can override to yield plot items for app-specific metadata entries (geometry results, table results)" +msgstr "Ajout du hook `iterate_metadata_shape_items()` sur `BaseObjPlotPyAdapter` — un générateur sans opération que les applications dérivées (DataLab) peuvent redéfinir pour produire des éléments de tracé pour les entrées de métadonnées spécifiques (résultats géométriques, résultats tabulaires)" + +msgid "Cleaned up commented-out scalar adapter code (`GeometryPlotPyAdapter`, `TableAdapter`) — these stay in DataLab" +msgstr "Nettoyage du code d'adaptateur scalaire mis en commentaire (`GeometryPlotPyAdapter`, `TableAdapter`) — ceux-ci restent dans DataLab" + +msgid "Environment and utilities" +msgstr "Environnement et utilitaires" + +msgid "Implemented `SGMXExecEnv` runtime environment singleton (renamed from DataLab's `DLExecEnv`) with verbosity levels, demo mode, and unattended mode" +msgstr "Implémentation du singleton d'environnement d'exécution `SGMXExecEnv` (renommé depuis `DLExecEnv` de DataLab) avec niveaux de verbosité, mode démo et mode sans interaction" + +msgid "Ported Qt helper utilities (`utils/qthelpers.py`): log file management, signal blocking context manager, stdout/stderr save/restore" +msgstr "Portage des utilitaires Qt (`utils/qthelpers.py`) : gestion des fichiers journaux, gestionnaire de contexte pour le blocage des signaux, sauvegarde/restauration de stdout/stderr" + +msgid "Added local PDF documentation path handling in the Help menu" +msgstr "Ajout de la gestion du chemin de documentation PDF locale dans le menu Aide" + +msgid "Package structure" +msgstr "Structure du paquet" + +msgid "Dissolved `gui/` subpackage — moved `gui/main.py` → `mainwindow.py` (top-level) and `gui/docks.py` → `widgets/plotdock.py` for clearer naming" +msgstr "Dissolution du sous-paquet `gui/` — déplacement de `gui/main.py` → `mainwindow.py` (niveau supérieur) et `gui/docks.py` → `widgets/plotdock.py` pour un nommage plus clair" + +msgid "Added `__all__` declarations in all public modules" +msgstr "Ajout des déclarations `__all__` dans tous les modules publics" + +msgid "Added `from __future__ import annotations` across all modules" +msgstr "Ajout de `from __future__ import annotations` dans tous les modules" + +msgid "Top-level `__init__.py` re-exports `SGMXMainWindow`, `create`, `run` (follows Sigima's pattern)" +msgstr "Le fichier `__init__.py` de niveau supérieur ré-exporte `SGMXMainWindow`, `create`, `run` (suit le modèle de Sigima)" + +msgid "Fixed circular import between `__init__.py` and `config.py` by extracting metadata to `_metadata.py`" +msgstr "Correction de l'import circulaire entre `__init__.py` et `config.py` par extraction des métadonnées dans `_metadata.py`" + +msgid "Removed dead code: commented-out imports, `config_old.py`, DataLab-specific action handler dependencies" +msgstr "Suppression du code mort : imports mis en commentaire, `config_old.py`, dépendances spécifiques au gestionnaire d'actions de DataLab" + +msgid "Testing" +msgstr "Tests" + +msgid "Built comprehensive test suite with 155+ tests organized into subpackages: `config/`, `mainwindow/`, `widgets/`, `hdf5/`, `adapters_plotpy/`, `utils/`" +msgstr "Construction d'une suite de tests complète avec plus de 155 tests organisés en sous-paquets : `config/`, `mainwindow/`, `widgets/`, `hdf5/`, `adapters_plotpy/`, `utils/`" + +msgid "Standardized test file naming to `test_*.py` convention; non-test helpers prefixed with `_`" +msgstr "Standardisation du nommage des fichiers de test selon la convention `test_*.py` ; les fichiers auxiliaires non-test sont préfixés par `_`" + +msgid "Added pytest markers: `@pytest.mark.unit` (pure logic, no Qt), `@pytest.mark.gui` (Qt widget tests), `@pytest.mark.app` (full main window)" +msgstr "Ajout de marqueurs pytest : `@pytest.mark.unit` (logique pure, sans Qt), `@pytest.mark.gui` (tests de widgets Qt), `@pytest.mark.app` (fenêtre principale complète)" + +msgid "Added `--show-windows` flag for visual test validation (offscreen by default)" +msgstr "Ajout du drapeau `--show-windows` pour la validation visuelle des tests (hors écran par défaut)" + +msgid "PlotPy adapter tests cover factory dispatch, make/update item roundtrips, ROI coordinate roundtrips, and annotation integration" +msgstr "Les tests des adaptateurs PlotPy couvrent la répartition des fabriques, les allers-retours de création/mise à jour d'éléments, les allers-retours de coordonnées ROI et l'intégration des annotations" + +msgid "Documentation" +msgstr "Documentation" + +msgid "Added full Sphinx documentation with API reference pages for all public modules (`app`, `config`, `env`, `mainwindow`, `widgets`, `h5`, `adapters_plotpy`, `utils`)" +msgstr "Ajout d'une documentation Sphinx complète avec des pages de référence API pour tous les modules publics (`app`, `config`, `env`, `mainwindow`, `widgets`, `h5`, `adapters_plotpy`, `utils`)" + +msgid "Added Sphinx-Gallery examples: getting started (`minimal_app.py`), features (`configuration.py`, `plot_widget.py`), and use cases (`full_app.py`)" +msgstr "Ajout d'exemples Sphinx-Gallery : premiers pas (`minimal_app.py`), fonctionnalités (`configuration.py`, `plot_widget.py`) et cas d'usage (`full_app.py`)" + +msgid "Added user guide pages: overview, installation, contributing" +msgstr "Ajout de pages du manuel utilisateur : aperçu, installation, contribution" + +msgid "Documentation builds cleanly with `-W` (warnings-as-errors)" +msgstr "La documentation se construit sans erreur avec `-W` (avertissements traités comme des erreurs)" + +msgid "Internationalization" +msgstr "Internationalisation" + +msgid "Added complete French translation of all 189 UI strings (`sigimax/locale/fr/LC_MESSAGES/sigimax.po`)" +msgstr "Ajout de la traduction française complète des 189 chaînes de l'interface (`sigimax/locale/fr/LC_MESSAGES/sigimax.po`)" + +msgid "Translations cover menus, toolbar, HDF5 browser, fit dialogs, signal widgets, status bar, error/warning dialogs, wizard, and about/help" +msgstr "Les traductions couvrent les menus, la barre d'outils, le navigateur HDF5, les boîtes de dialogue d'ajustement, les widgets de signaux, la barre d'état, les boîtes de dialogue d'erreur/avertissement, l'assistant et la boîte à propos/aide" + +msgid "Terminology aligned with DataLab's existing French translations for consistency" +msgstr "Terminologie alignée avec les traductions françaises existantes de DataLab pour la cohérence" + +msgid "Project infrastructure" +msgstr "Infrastructure du projet" + +msgid "Created `pyproject.toml` with Ruff rules (`D202`, `D403`, `RUF022`, Google pydocstyle), pytest config (`--import-mode=importlib`, `filterwarnings`)" +msgstr "Création de `pyproject.toml` avec les règles Ruff (`D202`, `D403`, `RUF022`, pydocstyle Google), la configuration pytest (`--import-mode=importlib`, `filterwarnings`)" + +msgid "Added Sphinx documentation scaffolding (imported from Sigima's structure)" +msgstr "Ajout de la structure de documentation Sphinx (importée depuis la structure de Sigima)" + +msgid "Added copilot instructions (`.github/copilot-instructions.md`)" +msgstr "Ajout des instructions Copilot (`.github/copilot-instructions.md`)" + +msgid "Changed maintainer email to `datalab@codra.fr`" +msgstr "Changement de l'adresse e-mail du mainteneur vers `datalab@codra.fr`" + +msgid "Fixed `run_with_env.py` to substitute `sys.executable` when command starts with `python`, ensuring the correct venv interpreter is used" +msgstr "Correction de `run_with_env.py` pour substituer `sys.executable` lorsque la commande commence par `python`, garantissant l'utilisation du bon interpréteur du venv" + diff --git a/doc/locale/fr/LC_MESSAGES/requirements.po b/doc/locale/fr/LC_MESSAGES/requirements.po new file mode 100644 index 0000000..658b77b --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/requirements.po @@ -0,0 +1,176 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "The `sigimax` package requires the following Python modules:" +msgstr "Le paquet `sigimax` nécessite les modules Python suivants :" + +msgid "Name" +msgstr "Nom" + +msgid "Version" +msgstr "Version" + +msgid "Summary" +msgstr "Résumé" + +msgid "Python" +msgstr "Python" + +msgid ">=3.9, <4" +msgstr ">=3.9, <4" + +msgid "Python programming language" +msgstr "Langage de programmation Python" + +msgid "guidata" +msgstr "guidata" + +msgid ">= 3.13.4" +msgstr ">= 3.13.4" + +msgid "Automatic GUI generation for easy dataset editing and display" +msgstr "Génération automatique d'interface graphique pour une édition et un affichage facile des jeux de données" + +msgid "PlotPy" +msgstr "PlotPy" + +msgid ">= 2.8.2" +msgstr ">= 2.8.2" + +msgid "Curve and image plotting tools for Python/Qt applications" +msgstr "Outils de traçage de courbes et d'images pour les applications Python/Qt" + +msgid "psutil" +msgstr "psutil" + +msgid ">= 5.7" +msgstr ">= 5.7" + +msgid "Cross-platform lib for process and system monitoring." +msgstr "Bibliothèque multiplateforme pour la surveillance des processus et du système." + +msgid "Sigima" +msgstr "Sigima" + +msgid ">= 1.1.0" +msgstr ">= 1.1.0" + +msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform." +msgstr "Moteur de calcul scientifique pour les signaux 1D et les images 2D, faisant partie de la plateforme open-source DataLab." + +msgid "Optional modules for GUI support (Qt):" +msgstr "Modules facultatifs pour la prise en charge de l'interface graphique (Qt) :" + +msgid "PyQt5" +msgstr "PyQt5" + +msgid ">= 5.15.6" +msgstr ">= 5.15.6" + +msgid "Python bindings for the Qt cross platform application toolkit" +msgstr "Liaisons Python pour l'outil de développement d'applications multiplateforme Qt" + +msgid "Optional modules for development:" +msgstr "Modules facultatifs pour le développement :" + +msgid "ruff" +msgstr "ruff" + +msgid "An extremely fast Python linter and code formatter, written in Rust." +msgstr "Un linter Python extrêmement rapide et un formateur de code, écrit en Rust." + +msgid "pylint" +msgstr "pylint" + +msgid "python code static checker" +msgstr "Analyseur statique de code Python" + +msgid "Coverage" +msgstr "Coverage" + +msgid "Code coverage measurement for Python" +msgstr "Mesure de la couverture du code pour Python" + +msgid "Optional modules for building the documentation:" +msgstr "Modules facultatifs pour la construction de la documentation :" + +msgid "sphinx" +msgstr "sphinx" + +msgid "Python documentation generator" +msgstr "Générateur de documentation Python" + +msgid "sphinx_intl" +msgstr "sphinx_intl" + +msgid "Sphinx utility that make it easy to translate and to apply translation." +msgstr "Utilitaire Sphinx qui facilite la traduction et l'application de la traduction." + +msgid "sphinx-sitemap" +msgstr "sphinx-sitemap" + +msgid "Sitemap generator for Sphinx" +msgstr "Générateur de plan de site pour Sphinx" + +msgid "myst_parser" +msgstr "myst_parser" + +msgid "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," +msgstr "Un analyseur conforme à [CommonMark](https://spec.commonmark.org/) étendu," + +msgid "myst-nb" +msgstr "myst-nb" + +msgid "A Jupyter Notebook Sphinx reader built on top of the MyST markdown parser." +msgstr "Un lecteur Sphinx de notebooks Jupyter construit au-dessus de l'analyseur Markdown MyST." + +msgid "sphinx_design" +msgstr "sphinx_design" + +msgid "A sphinx extension for designing beautiful, view size responsive web components." +msgstr "Une extension Sphinx pour concevoir de beaux composants web réactifs à la taille de la vue." + +msgid "sphinx_gallery" +msgstr "sphinx_gallery" + +msgid "A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts." +msgstr "Une extension Sphinx qui crée une galerie HTML d'exemples à partir de n'importe quel ensemble de scripts Python." + +msgid "sphinx-copybutton" +msgstr "sphinx-copybutton" + +msgid "Add a copy button to each of your code cells." +msgstr "Ajoute un bouton de copie à chacune de vos cellules de code." + +msgid "pydata-sphinx-theme" +msgstr "pydata-sphinx-theme" + +msgid "Bootstrap-based Sphinx theme from the PyData community" +msgstr "Thème Sphinx basé sur Bootstrap de la communauté PyData" + +msgid "Optional modules for running test suite:" +msgstr "Modules facultatifs pour exécuter la suite de tests :" + +msgid "pytest" +msgstr "pytest" + +msgid "pytest: simple powerful testing with Python" +msgstr "pytest : tests simples et puissants avec Python" + +msgid "pytest-xvfb" +msgstr "pytest-xvfb" + +msgid "A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests." +msgstr "Un plugin pytest pour exécuter Xvfb (ou Xephyr/Xvnc) pour les tests." + diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/features.po b/doc/locale/fr/LC_MESSAGES/user_guide/features.po new file mode 100644 index 0000000..b1ea7fd --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/features.po @@ -0,0 +1,284 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Features" +msgstr "Fonctionnalités" + +msgid "This page provides an organized catalog of SigimaX's key features." +msgstr "Cette page fournit un catalogue organisé des principales fonctionnalités de SigimaX." + +msgid "Application Framework" +msgstr "Framework applicatif" + +msgid "Feature" +msgstr "Fonctionnalité" + +msgid "Description" +msgstr "Description" + +msgid ":class:`~sigimax.mainwindow.SGMXMainWindow`" +msgstr ":class:`~sigimax.mainwindow.SGMXMainWindow`" + +msgid "Generic main window with customizable menus, toolbars, console, and dock widgets. Derived apps subclass this to build their own UI." +msgstr "Fenêtre principale générique avec menus, barres d'outils, console et widgets ancrables personnalisables. Les applications dérivées en héritent pour construire leur propre interface." + +msgid ":func:`~sigimax.app.create`" +msgstr ":func:`~sigimax.app.create`" + +msgid "Instantiate a main window with optional splash screen, console, and size. Returns the window instance for embedding." +msgstr "Instancie une fenêtre principale avec écran de démarrage, console et taille optionnels. Retourne l'instance de la fenêtre pour l'intégration." + +msgid ":func:`~sigimax.app.run`" +msgstr ":func:`~sigimax.app.run`" + +msgid "Create the window and enter the Qt event loop. The standard entry point for standalone applications." +msgstr "Crée la fenêtre et entre dans la boucle d'événements Qt. Le point d'entrée standard pour les applications autonomes." + +msgid ":class:`~sigimax.widgets.splashscreen.SplashScreenConfig`" +msgstr ":class:`~sigimax.widgets.splashscreen.SplashScreenConfig`" + +msgid "Configurable splash screen with image, app name, version, tagline, and optional progress display." +msgstr "Écran de démarrage configurable avec image, nom de l'application, version, slogan et affichage optionnel de la progression." + +msgid "Configuration System" +msgstr "Système de configuration" + +msgid ":class:`~sigimax.config.SigimaXOptions`" +msgstr ":class:`~sigimax.config.SigimaXOptions`" + +msgid "Full configuration singleton (``CONF``) with 20+ typed options covering app metadata, plot defaults, HDF5 settings, and more." +msgstr "Singleton de configuration complet (``CONF``) avec plus de 20 options typées couvrant les métadonnées de l'application, les paramètres par défaut des tracés, les paramètres HDF5, et plus encore." + +msgid "``EnumOptionField``" +msgstr "``EnumOptionField``" + +msgid "Option constrained to a set of string choices, with validation." +msgstr "Option contrainte à un ensemble de choix de chaînes de caractères, avec validation." + +msgid "``TupleOptionField``" +msgstr "``TupleOptionField``" + +msgid "Fixed-length tuple option with type checking." +msgstr "Option de tuple de longueur fixe avec vérification de type." + +msgid "``FontOptionField``" +msgstr "``FontOptionField``" + +msgid "Font option with validation against available system fonts." +msgstr "Option de police avec validation par rapport aux polices système disponibles." + +msgid "JSON persistence" +msgstr "Persistance JSON" + +msgid "``save()`` / ``load()`` methods for configuration persistence." +msgstr "Méthodes ``save()`` / ``load()`` pour la persistance de la configuration." + +msgid "Context managers" +msgstr "Gestionnaires de contexte" + +msgid "Temporary overrides with ``option.context(value)`` pattern." +msgstr "Surcharges temporaires avec le patron ``option.context(value)``." + +msgid "Configuration options reference" +msgstr "Référence des options de configuration" + +msgid "The full list of configuration options available in the ``CONF`` singleton:" +msgstr "La liste complète des options de configuration disponibles dans le singleton ``CONF`` :" + +msgid "HDF5 Workspace" +msgstr "Espace de travail HDF5" + +msgid "Open/Save workspace" +msgstr "Ouvrir/Enregistrer l'espace de travail" + +msgid "File actions and workspace-state handling. Derived applications provide their own workspace serialization." +msgstr "Actions de fichier et gestion de l'état de l'espace de travail. Les applications dérivées fournissent leur propre sérialisation de l'espace de travail." + +msgid ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" +msgstr ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" + +msgid "Interactive HDF5 file browser with tree view, supporting scalar, array, text, and compound datasets." +msgstr "Explorateur interactif de fichiers HDF5 avec vue en arbre, supportant les jeux de données scalaires, tableaux, texte et composés." + +msgid "Import datasets" +msgstr "Importer des jeux de données" + +msgid "``import_dataset_from_file()`` hook for derived apps to handle application-specific HDF5 dataset import." +msgstr "Hook ``import_dataset_from_file()`` pour que les applications dérivées gèrent l'import de jeux de données HDF5 spécifiques à l'application." + +msgid ":class:`~sigimax.h5.H5Importer`" +msgstr ":class:`~sigimax.h5.H5Importer`" + +msgid "Low-level HDF5 import utilities with node factory and data extraction." +msgstr "Utilitaires d'import HDF5 bas niveau avec fabrique de nœuds et extraction de données." + +msgid "See :doc:`hdf5_workspace` for the persistence contract and its complete derived-application example." +msgstr "Voir :doc:`hdf5_workspace` pour le contrat de persistance et son exemple complet d'application dérivée." + +msgid "Scientific Widgets" +msgstr "Widgets scientifiques" + +msgid "Widget" +msgstr "Widget" + +msgid ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" +msgstr ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" + +msgid "Embeds PlotPy plots in dock widgets, with configurable watermark and dock location. Supports ``PlotType.CURVE`` and ``PlotType.IMAGE``." +msgstr "Intègre des tracés PlotPy dans des widgets ancrables, avec filigrane et position d'ancrage configurables. Supporte ``PlotType.CURVE`` et ``PlotType.IMAGE``." + +msgid "Curve fitting dialogs" +msgstr "Dialogues d'ajustement de courbe" + +msgid "Gaussian, polynomial, and custom curve fitting via :mod:`sigimax.widgets.fitdialog`." +msgstr "Ajustement de courbe gaussien, polynomial et personnalisé via :mod:`sigimax.widgets.fitdialog`." + +msgid "Signal peak detection" +msgstr "Détection de pics de signal" + +msgid "Interactive peak detection dialog via :mod:`sigimax.widgets.signalpeak`." +msgstr "Dialogue de détection de pics interactif via :mod:`sigimax.widgets.signalpeak`." + +msgid "Signal baseline selection" +msgstr "Sélection de ligne de base" + +msgid "Baseline selection for background subtraction via :mod:`sigimax.widgets.signalbaseline`." +msgstr "Sélection de la ligne de base pour la soustraction du fond via :mod:`sigimax.widgets.signalbaseline`." + +msgid "Signal cursor" +msgstr "Curseur de signal" + +msgid "Cursor-based value readout via :mod:`sigimax.widgets.signalcursor`." +msgstr "Lecture de valeur par curseur via :mod:`sigimax.widgets.signalcursor`." + +msgid "Signal delta-X" +msgstr "Delta-X de signal" + +msgid "Delta-X measurement between two points via :mod:`sigimax.widgets.signaldeltax`." +msgstr "Mesure du delta-X entre deux points via :mod:`sigimax.widgets.signaldeltax`." + +msgid "Image background" +msgstr "Fond d'image" + +msgid "Image background region selection via :mod:`sigimax.widgets.imagebackground`." +msgstr "Sélection de la région de fond d'image via :mod:`sigimax.widgets.imagebackground`." + +msgid ":class:`~sigimax.widgets.wizard.Wizard`" +msgstr ":class:`~sigimax.widgets.wizard.Wizard`" + +msgid "Multi-page wizard dialog with navigation, validation, and data collection (Next/Back/Finish/Cancel)." +msgstr "Dialogue assistant multi-pages avec navigation, validation et collecte de données (Suivant/Précédent/Terminer/Annuler)." + +msgid ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" +msgstr ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" + +msgid "Log viewer dialog for displaying application log files." +msgstr "Dialogue de visualisation des journaux pour l'affichage des fichiers journaux de l'application." + +msgid ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" +msgstr ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" + +msgid "Warning/error display dialog with traceback support." +msgstr "Dialogue d'affichage des avertissements et erreurs avec support des traces d'exécution." + +msgid "Status Bar Widgets" +msgstr "Widgets de barre d'état" + +msgid ":class:`~sigimax.widgets.status.MemoryStatus`" +msgstr ":class:`~sigimax.widgets.status.MemoryStatus`" + +msgid "Displays current memory usage with configurable alarm threshold." +msgstr "Affiche l'utilisation mémoire courante avec un seuil d'alarme configurable." + +msgid ":class:`~sigimax.widgets.status.ConsoleStatus`" +msgstr ":class:`~sigimax.widgets.status.ConsoleStatus`" + +msgid "Console toggle button in the status bar." +msgstr "Bouton d'activation/désactivation de la console dans la barre d'état." + +msgid ":class:`~sigimax.widgets.status.BaseStatus`" +msgstr ":class:`~sigimax.widgets.status.BaseStatus`" + +msgid "Base status bar widget for custom status indicators." +msgstr "Widget de barre d'état de base pour les indicateurs d'état personnalisés." + +msgid "PlotPy Adapters" +msgstr "Adaptateurs PlotPy" + +msgid "Signal/Image adapters" +msgstr "Adaptateurs Signal/Image" + +msgid "Convert between Sigima objects (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`) and PlotPy plot items for display." +msgstr "Conversion entre les objets Sigima (:class:`~sigima.objects.SignalObj`, :class:`~sigima.objects.ImageObj`) et les éléments de tracé PlotPy pour l'affichage." + +msgid "ROI adapters" +msgstr "Adaptateurs ROI" + +msgid "Convert between Sigima ROI objects and PlotPy annotation items (segment, rectangular, circular, polygonal)." +msgstr "Conversion entre les objets ROI Sigima et les éléments d'annotation PlotPy (segment, rectangulaire, circulaire, polygonal)." + +msgid ":func:`~sigimax.adapters_plotpy.create_adapter_from_object`" +msgstr ":func:`~sigimax.adapters_plotpy.create_adapter_from_object`" + +msgid "Factory function that dispatches to the correct adapter based on object type." +msgstr "Fonction fabrique qui redirige vers l'adaptateur correct en fonction du type d'objet." + +msgid "JSON roundtrips" +msgstr "Allers-retours JSON" + +msgid "``items_to_json()`` / ``json_to_items()`` for serializing plot items." +msgstr "``items_to_json()`` / ``json_to_items()`` pour la sérialisation des éléments de tracé." + +msgid "Runtime Environment" +msgstr "Environnement d'exécution" + +msgid ":class:`~sigimax.env.SGMXExecEnv`" +msgstr ":class:`~sigimax.env.SGMXExecEnv`" + +msgid "Runtime environment singleton (``execenv``) controlling unattended mode, verbosity levels, demo mode, screenshot capture, and delay settings." +msgstr "Singleton d'environnement d'exécution (``execenv``) contrôlant le mode sans interaction, les niveaux de verbosité, le mode démo, la capture d'écran et les paramètres de délai." + +msgid ":class:`~sigimax.env.VerbosityLevels`" +msgstr ":class:`~sigimax.env.VerbosityLevels`" + +msgid "Enum with ``quiet``, ``normal``, and ``debug`` verbosity levels." +msgstr "\\u00c9num\\u00e9ration avec les niveaux de verbosit\\u00e9 ``quiet``, ``normal`` et ``debug``." + +msgid "Command-line arguments" +msgstr "Arguments en ligne de commande" + +msgid "``--unattended``, ``--verbose``, ``--screenshot``, ``--delay``, ``--version``, ``--reset`` parsed automatically on startup." +msgstr "``--unattended``, ``--verbose``, ``--screenshot``, ``--delay``, ``--version``, ``--reset`` analysés automatiquement au démarrage." + +msgid "Context manager" +msgstr "Gestionnaire de contexte" + +msgid "``execenv.context()`` for temporarily overriding environment settings." +msgstr "``execenv.context()`` pour surcharger temporairement les paramètres d'environnement." + +msgid "Internationalization" +msgstr "Internationalisation" + +msgid "SigimaX supports internationalization with gettext:" +msgstr "SigimaX supporte l'internationalisation avec gettext :" + +msgid "All UI strings are wrapped with ``_()`` from :mod:`sigimax.config`" +msgstr "Toutes les chaînes de l'interface sont encapsulées avec ``_()`` depuis :mod:`sigimax.config`" + +msgid "Translations are stored in ``locale/`` (currently English and French)" +msgstr "Les traductions sont stockées dans ``locale/`` (actuellement anglais et français)" + +msgid "Use ``guidata.utils.translations`` CLI to scan and compile translations" +msgstr "Utilisez la CLI ``guidata.utils.translations`` pour analyser et compiler les traductions" + diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/getting_started.po b/doc/locale/fr/LC_MESSAGES/user_guide/getting_started.po new file mode 100644 index 0000000..325172d --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/getting_started.po @@ -0,0 +1,155 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Getting Started" +msgstr "Premiers pas" + +msgid "This page provides a quick introduction to building applications with SigimaX." +msgstr "Cette page fournit une introduction rapide à la construction d'applications avec SigimaX." + +#, fuzzy +msgid "SigimaX follows a **three-step derivation pattern**: subclass the configuration, subclass the main window, and launch with ``run()``. This pattern is proven in production — `DataLab `_ is built entirely on it." +msgstr "SigimaX suit un **patron de d\\u00e9rivation en trois \\u00e9tapes** : h\\u00e9riter de la configuration, h\\u00e9riter de la fen\\u00eatre principale, et lancer avec ``run()``. Ce patron est \\u00e9prouv\\u00e9 en production \\u2014 `DataLab `_ est enti\\u00e8rement construit dessus." + +msgid "The Derivation Pattern" +msgstr "Le patron de dérivation" + +msgid "**Step 1 — Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific configuration fields:" +msgstr "**Étape 1 — Hériter de** :class:`~sigimax.config.SigimaXOptions` pour ajouter des champs de configuration spécifiques à l'application :" + +msgid "**Step 2 — Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize the user interface:" +msgstr "**Étape 2 — Hériter de** :class:`~sigimax.mainwindow.SGMXMainWindow` pour personnaliser l'interface utilisateur :" + +msgid "**Step 3 — Launch** the application:" +msgstr "**Étape 3 — Lancer** l'application :" + +msgid "That's it. Three classes, three steps — and you have a full-featured scientific desktop application with menus, toolbars, console, HDF5 workspace, and status bar." +msgstr "C'est tout. Trois classes, trois étapes — et vous avez une application scientifique de bureau complète avec menus, barres d'outils, console, espace de travail HDF5 et barre d'état." + +msgid "Key Concepts" +msgstr "Concepts clés" + +#, fuzzy +msgid "**Configuration system**: Options are typed fields (``TypedOptionField``, ``EnumOptionField``, ``TupleOptionField``) that support ``get()``/``set()``/ ``context()`` API and JSON persistence." +msgstr "**Système de configuration** : les options sont des champs typés (``TypedOptionField``, ``EnumOptionField``, ``TupleOptionField``) qui supportent l'API ``get()``/``set()``/ ``context()``, la persistance JSON et la synchronisation avec les variables d'environnement." + +#, fuzzy +msgid "**Overridable hooks**: The main window provides hooks that derived apps can override: ``reset_all()``, ``_is_save_enabled()``, ``_update_file_menu()``, ``_update_view_menu()``, ``_about()``, ``_before_setup()``, and ``_after_setup()``. See :doc:`lifecycle` for their ordering and initialization contract." +msgstr "**Hooks redéfinissables** : la fenêtre principale fournit des hooks que les applications dérivées peuvent redéfinir : ``reset_all()``, ``_is_save_enabled()``, ``_update_file_menu()``, ``_update_view_menu()``, ``_about()``." + +msgid "**Built-in features**: HDF5 GUI and generic dataset import; derived applications implement their own workspace persistence. SigimaX also provides an embedded Python console, status bar with memory monitoring, and splash screen support. See :doc:`hdf5_workspace` for the derived-application contract." +msgstr "**Fonctionnalités intégrées** : interface HDF5 et import générique de jeux de données ; les applications dérivées implémentent leur propre persistance de l'espace de travail. SigimaX fournit également une console Python intégrée, une barre d'état avec surveillance mémoire et la prise en charge d'un écran de démarrage. Voir :doc:`hdf5_workspace` pour le contrat des applications dérivées." + +msgid "What's Included" +msgstr "Contenu inclus" + +msgid "SigimaX provides 15+ ready-to-use scientific widgets:" +msgstr "SigimaX fournit plus de 15 widgets scientifiques prêts à l'emploi :" + +msgid "Widget" +msgstr "Widget" + +msgid "Purpose" +msgstr "Objectif" + +msgid ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" +msgstr ":class:`~sigimax.widgets.plotdock.DockablePlotWidget`" + +msgid "Embeddable PlotPy plot in dock widgets" +msgstr "Tracé PlotPy intégrable dans des widgets ancrables" + +msgid ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" +msgstr ":class:`~sigimax.widgets.h5browser.H5BrowserDialog`" + +msgid "Interactive HDF5 file browser" +msgstr "Explorateur interactif de fichiers HDF5" + +msgid ":class:`~sigimax.widgets.fitdialog`" +msgstr ":class:`~sigimax.widgets.fitdialog`" + +msgid "Curve fitting dialogs (Gaussian, polynomial, etc.)" +msgstr "Dialogues d'ajustement de courbe (gaussien, polynomial, etc.)" + +msgid ":class:`~sigimax.widgets.signalpeak`" +msgstr ":class:`~sigimax.widgets.signalpeak`" + +msgid "Signal peak detection dialog" +msgstr "Dialogue de détection de pics de signal" + +msgid ":class:`~sigimax.widgets.signalbaseline`" +msgstr ":class:`~sigimax.widgets.signalbaseline`" + +msgid "Signal baseline selection dialog" +msgstr "Dialogue de sélection de ligne de base" + +msgid ":class:`~sigimax.widgets.signalcursor`" +msgstr ":class:`~sigimax.widgets.signalcursor`" + +msgid "Signal cursor selection dialog" +msgstr "Dialogue de sélection par curseur" + +msgid ":class:`~sigimax.widgets.signaldeltax`" +msgstr ":class:`~sigimax.widgets.signaldeltax`" + +msgid "Signal delta-X measurement dialog" +msgstr "Dialogue de mesure de delta-X" + +msgid ":class:`~sigimax.widgets.imagebackground`" +msgstr ":class:`~sigimax.widgets.imagebackground`" + +msgid "Image background selection dialog" +msgstr "Dialogue de sélection du fond d'image" + +msgid ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" +msgstr ":class:`~sigimax.widgets.logviewer.LogViewerWindow`" + +msgid "Log viewer dialog" +msgstr "Dialogue de visualisation des journaux" + +msgid ":class:`~sigimax.widgets.wizard.Wizard`" +msgstr ":class:`~sigimax.widgets.wizard.Wizard`" + +msgid "Multi-page wizard dialog" +msgstr "Dialogue assistant multi-pages" + +msgid ":class:`~sigimax.widgets.splashscreen.SigimaXSplashScreen`" +msgstr ":class:`~sigimax.widgets.splashscreen.SigimaXSplashScreen`" + +msgid "Configurable splash screen" +msgstr "Écran de démarrage configurable" + +msgid ":class:`~sigimax.widgets.status.MemoryStatus`" +msgstr ":class:`~sigimax.widgets.status.MemoryStatus`" + +msgid "Status bar memory usage widget" +msgstr "Widget de barre d'état d'utilisation mémoire" + +msgid ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" +msgstr ":class:`~sigimax.widgets.warningerror.WarningErrorMessageBox`" + +msgid "Warning/error display dialog" +msgstr "Dialogue d'affichage des avertissements et erreurs" + +msgid "Next Steps" +msgstr "Étapes suivantes" + +msgid "Browse the :doc:`../auto_examples/index` to see SigimaX in action" +msgstr "Parcourez les :doc:`../auto_examples/index` pour voir SigimaX en action" + +msgid "Read the :doc:`overview` for architecture details" +msgstr "Lisez la :doc:`overview` pour les détails de l'architecture" + +msgid "Dive into the :doc:`/api/index` for complete reference documentation" +msgstr "Plongez dans la :doc:`/api/index` pour la documentation de référence complète" + diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/hdf5_workspace.po b/doc/locale/fr/LC_MESSAGES/user_guide/hdf5_workspace.po new file mode 100644 index 0000000..6e34087 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/hdf5_workspace.po @@ -0,0 +1,95 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "HDF5 Workspace" +msgstr "Espace de travail HDF5" + +msgid "SigimaX provides the HDF5 user interface and generic dataset import. A derived application owns its workspace model and its workspace file format. SigimaX does not define a universal workspace schema." +msgstr "SigimaX fournit l'interface utilisateur HDF5 et l'import générique de jeux de données. Une application dérivée possède son propre modèle d'espace de travail et son propre format de fichier d'espace de travail. SigimaX ne définit pas de schéma d'espace de travail universel." + +msgid "Responsibilities" +msgstr "Responsabilités" + +msgid "SigimaX provides:" +msgstr "SigimaX fournit :" + +msgid "File, Open/Import, Browse, and Save actions;" +msgstr "les actions Fichier, Ouvrir/Importer, Parcourir et Enregistrer ;" + +msgid "the HDF5 browser and :class:`sigimax.h5.H5Importer` for arbitrary datasets;" +msgstr "le navigateur HDF5 et :class:`sigimax.h5.H5Importer` pour des jeux de données arbitraires ;" + +msgid ":meth:`sigimax.mainwindow.SGMXMainWindow.set_modified`, the save prompt, and the close workflow;" +msgstr ":meth:`sigimax.mainwindow.SGMXMainWindow.set_modified`, l'invite d'enregistrement et le flux de fermeture ;" + +msgid "protected HDF5 hooks for derived applications." +msgstr "des points d'extension HDF5 protégés pour les applications dérivées." + +msgid "A derived application provides:" +msgstr "Une application dérivée fournit :" + +msgid "its data model;" +msgstr "son modèle de données ;" + +msgid "workspace serialization and deserialization;" +msgstr "la sérialisation et la désérialisation de l'espace de travail ;" + +msgid "the mutation points that mark the workspace modified;" +msgstr "les points de mutation qui marquent l'espace de travail comme modifié ;" + +msgid "optional application-specific dataset import." +msgstr "l'import de jeux de données spécifique à l'application, optionnel." + +msgid "A base :class:`~sigimax.mainwindow.SGMXMainWindow` has no data model. Its Save action remains disabled and a direct call to ``save_h5_workspace`` raises :class:`NotImplementedError`. This prevents a successful-looking save from silently discarding a modified workspace." +msgstr "Une :class:`~sigimax.mainwindow.SGMXMainWindow` de base n'a pas de modèle de données. Son action Enregistrer reste désactivée et un appel direct à ``save_h5_workspace`` lève :class:`NotImplementedError`. Cela évite qu'un enregistrement apparemment réussi ne rejette silencieusement un espace de travail modifié." + +msgid "Minimal Derived Application" +msgstr "Application dérivée minimale" + +msgid "The SigimaX test suite contains an executable reference application. It is both an integration test and a complete minimal example: it stores signals and images, imports generic HDF5 datasets, and writes a small workspace format." +msgstr "La suite de tests de SigimaX contient une application de référence exécutable. C'est à la fois un test d'intégration et un exemple minimal complet : elle stocke des signaux et des images, importe des jeux de données HDF5 génériques et écrit un petit format d'espace de travail." + +msgid "Its data model deliberately remains application code. It uses two collections and knows how to serialize them with ``guidata``:" +msgstr "Son modèle de données reste délibérément du code applicatif. Elle utilise deux collections et sait les sérialiser avec ``guidata`` :" + +msgid "The derived window validates paths through the protected hook, writes its model, and only clears the modified flag after a successful write:" +msgstr "La fenêtre dérivée valide les chemins via le point d'extension protégé, écrit son modèle, et ne réinitialise l'indicateur modifié qu'après une écriture réussie :" + +msgid "The matching loader is equally application-specific:" +msgstr "Le chargeur correspondant est tout aussi spécifique à l'application :" + +msgid "Workspace State and Save" +msgstr "État de l'espace de travail et enregistrement" + +msgid "Call ``set_modified(True)`` whenever an application mutation changes the workspace. The framework adds an asterisk to the window title, enables Save for windows that implement ``save_h5_workspace``, and asks for confirmation when the user closes a modified window." +msgstr "Appelez ``set_modified(True)`` chaque fois qu'une mutation applicative modifie l'espace de travail. Le framework ajoute un astérisque au titre de la fenêtre, active Enregistrer pour les fenêtres qui implémentent ``save_h5_workspace``, et demande confirmation lorsque l'utilisateur ferme une fenêtre modifiée." + +msgid "A successful ``save_h5_workspace`` implementation must call ``set_modified(False)`` only after its writer closes without an exception. An exception or a cancelled save leaves the workspace modified, so the close flow remains safe." +msgstr "Une implémentation réussie de ``save_h5_workspace`` ne doit appeler ``set_modified(False)`` qu'après que son writer se soit fermé sans exception. Une exception ou un enregistrement annulé laisse l'espace de travail modifié, ce qui garde le flux de fermeture sûr." + +msgid "Generic Dataset Import" +msgstr "Import générique de jeux de données" + +msgid "``open_h5_files`` and :class:`~sigimax.widgets.h5browser.H5BrowserDialog` are for importing arbitrary HDF5 datasets. They are independent from an application's workspace loader. The reference application connects ``SIG_SEND_OBJECTLIST`` to its model and implements ``import_dataset_from_file`` for programmatic selection of a dataset." +msgstr "``open_h5_files`` et :class:`~sigimax.widgets.h5browser.H5BrowserDialog` servent à importer des jeux de données HDF5 arbitraires. Ils sont indépendants du chargeur d'espace de travail d'une application. L'application de référence connecte ``SIG_SEND_OBJECTLIST`` à son modèle et implémente ``import_dataset_from_file`` pour la sélection programmatique d'un jeu de données." + +msgid "The generic import layer can create :class:`sigima.objects.SignalObj` and :class:`sigima.objects.ImageObj`; a derived application decides how these objects enter its own model." +msgstr "La couche d'import générique peut créer des :class:`sigima.objects.SignalObj` et :class:`sigima.objects.ImageObj` ; une application dérivée décide comment ces objets entrent dans son propre modèle." + +msgid "DataLab" +msgstr "DataLab" + +msgid "DataLab keeps its native workspace format, panel serialization, metadata, ROI, and analysis-result handling. Its ``save_h5_workspace`` override remains the owner of that format. The SigimaX HDF5 GUI and generic import infrastructure do not change DataLab's native HDF5 layout." +msgstr "DataLab conserve son format d'espace de travail natif, la sérialisation de ses panneaux, ses métadonnées, ses ROI et la gestion des résultats d'analyse. Sa surcharge de ``save_h5_workspace`` reste propriétaire de ce format. L'interface HDF5 et l'infrastructure d'import générique de SigimaX ne modifient pas la disposition HDF5 native de DataLab." + diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/index.po b/doc/locale/fr/LC_MESSAGES/user_guide/index.po new file mode 100644 index 0000000..9d2d66c --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/index.po @@ -0,0 +1,43 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Contents:" +msgstr "Sommaire :" + +msgid "User Guide" +msgstr "Manuel utilisateur" + +msgid " Installation" +msgstr " Installation" + +msgid "How to install SigimaX" +msgstr "Comment installer SigimaX" + +msgid " Getting Started" +msgstr " Premiers pas" + +msgid "Build your first app in minutes" +msgstr "Construisez votre première application en quelques minutes" + +msgid " Overview" +msgstr " Aperçu" + +msgid "Architecture and design philosophy" +msgstr "Architecture et philosophie de conception" + +msgid " Features" +msgstr " Fonctionnalités" + +msgid "Key functionalities" +msgstr "Fonctionnalités clés" diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/installation.po b/doc/locale/fr/LC_MESSAGES/user_guide/installation.po new file mode 100644 index 0000000..5d879cc --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/installation.po @@ -0,0 +1,248 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Installation" +msgstr "Installation" + +msgid "This section provides instructions on how to install and set up SigimaX, including dependencies and environment configuration." +msgstr "Cette section fournit les instructions pour installer et configurer SigimaX, y compris les dépendances et la configuration de l'environnement." + +msgid "How to install" +msgstr "Modes d'installation" + +msgid "SigimaX is available in several forms:" +msgstr "SigimaX est disponible sous plusieurs formes :" + +msgid "As a Python package, which can be installed using the :ref:`install_pip`." +msgstr "En tant que paquet Python, qui peut être installé en utilisant le :ref:`install_pip`." + +msgid "As a precompiled :ref:`install_wheel`, which can be installed using ``pip``." +msgstr "En tant que :ref:`install_wheel` précompilé, qui peut être installé en utilisant ``pip``." + +msgid "As a :ref:`install_source`, which can be installed from the Git repository." +msgstr "En tant que :ref:`install_source`, qui peut être installé depuis le dépôt Git." + +msgid "Impatient to try the next version of SigimaX? You can also install the latest development version from the main branch of the Git repository. See :ref:`install_development` for more information." +msgstr "Impatient d'essayer la prochaine version de SigimaX ? Vous pouvez également installer la dernière version de développement depuis la branche principale du dépôt Git. Voir :ref:`install_development` pour plus d'informations." + +msgid "Package manager ``pip``" +msgstr "Gestionnaire de paquets ``pip``" + +msgid ":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS`" +msgstr ":octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS`" + +msgid "SigimaX's package ``sigimax`` is available on the Python Package Index (PyPI) at: https://pypi.python.org/pypi/sigimax." +msgstr "Le paquet ``sigimax`` de SigimaX est disponible sur le Python Package Index (PyPI) à l'adresse : https://pypi.python.org/pypi/sigimax." + +msgid "Install SigimaX by running:" +msgstr "Installez SigimaX en exécutant :" + +msgid "If you already have a previous version of SigimaX installed, you can upgrade it by running the same command with the ``--upgrade`` option:" +msgstr "Si vous avez déjà une version précédente de SigimaX installée, vous pouvez la mettre à jour en exécutant la même commande avec l'option ``--upgrade`` :" + +msgid "Wheel package" +msgstr "Paquet Wheel" + +msgid "On any operating system, using pip and the Wheel package is the easiest way to install SigimaX on an existing Python distribution:" +msgstr "Sur tout système d'exploitation, utiliser pip et le paquet Wheel est le moyen le plus simple d'installer SigimaX sur une distribution Python existante :" + +msgid "Source package" +msgstr "Paquet source" + +msgid "Installing SigimaX directly from the source package may be done using ``pip``:" +msgstr "L'installation de SigimaX directement depuis le paquet source peut être effectuée en utilisant ``pip`` :" + +msgid "Or, if you prefer, you can install it in editable mode from the root directory of the source package:" +msgstr "Ou, si vous préférez, vous pouvez l'installer en mode éditable depuis le répertoire racine du paquet source :" + +msgid "Development version" +msgstr "Version de développement" + +msgid "If you want to try the latest development version of SigimaX, you can install it directly from the main branch of the Git repository." +msgstr "Si vous souhaitez essayer la dernière version de développement de SigimaX, vous pouvez l'installer directement depuis la branche principale du dépôt Git." + +msgid "The first time you install SigimaX from the Git repository, enter the following command:" +msgstr "La première fois que vous installez SigimaX depuis le dépôt Git, entrez la commande suivante :" + +msgid "Then, if at some point you want to upgrade to the latest version, run:" +msgstr "Ensuite, si à un moment donné vous souhaitez mettre à jour vers la dernière version, exécutez :" + +msgid "If dependencies have changed, you may need to execute the same command without the ``--no-deps`` option." +msgstr "Si les dépendances ont changé, vous pourriez avoir besoin d'exécuter la même commande sans l'option ``--no-deps``." + +msgid "Dependencies" +msgstr "Dépendances" + +msgid "The `sigimax` package requires the following Python modules:" +msgstr "Le paquet `sigimax` nécessite les modules Python suivants :" + +msgid "Name" +msgstr "Nom" + +msgid "Version" +msgstr "Version" + +msgid "Summary" +msgstr "Description" + +msgid "Python" +msgstr "Python" + +msgid ">=3.9, <4" +msgstr ">=3.9, <4" + +msgid "Python programming language" +msgstr "Langage de programmation Python" + +msgid "guidata" +msgstr "guidata" + +msgid ">= 3.13.4" +msgstr ">= 3.13.4" + +msgid "Automatic GUI generation for easy dataset editing and display" +msgstr "Génération automatique d'interface graphique pour une édition et un affichage facile des jeux de données" + +msgid "PlotPy" +msgstr "PlotPy" + +msgid ">= 2.8.2" +msgstr ">= 2.8.2" + +msgid "Curve and image plotting tools for Python/Qt applications" +msgstr "Outils de tracé de courbes et d'images pour les applications Python/Qt" + +msgid "psutil" +msgstr "psutil" + +msgid ">= 5.7" +msgstr ">= 5.7" + +msgid "Cross-platform lib for process and system monitoring." +msgstr "Bibliothèque multiplateforme pour la surveillance des processus et du système." + +msgid "Sigima" +msgstr "Sigima" + +msgid ">= 1.1.0" +msgstr ">= 1.1.0" + +msgid "Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform." +msgstr "Moteur de calcul scientifique pour les signaux 1D et les images 2D, partie de la plateforme open-source DataLab." + +msgid "Optional modules for GUI support (Qt):" +msgstr "Modules optionnels pour le support de l'interface graphique (Qt) :" + +msgid "PyQt5" +msgstr "PyQt5" + +msgid ">= 5.15.6" +msgstr ">= 5.15.6" + +msgid "Python bindings for the Qt cross platform application toolkit" +msgstr "Liaisons Python pour la boîte à outils d'applications multiplateformes Qt" + +msgid "Optional modules for development:" +msgstr "Modules optionnels pour le développement :" + +msgid "ruff" +msgstr "ruff" + +msgid "An extremely fast Python linter and code formatter, written in Rust." +msgstr "Un linter et formateur de code Python extrêmement rapide, écrit en Rust." + +msgid "pylint" +msgstr "pylint" + +msgid "python code static checker" +msgstr "Vérificateur statique de code Python" + +msgid "Coverage" +msgstr "Coverage" + +msgid "Code coverage measurement for Python" +msgstr "Mesure de la couverture de code pour Python" + +msgid "Optional modules for building the documentation:" +msgstr "Modules optionnels pour la construction de la documentation :" + +msgid "sphinx" +msgstr "sphinx" + +msgid "Python documentation generator" +msgstr "Générateur de documentation Python" + +msgid "sphinx_intl" +msgstr "sphinx_intl" + +msgid "Sphinx utility that make it easy to translate and to apply translation." +msgstr "Utilitaire Sphinx qui facilite la traduction et l'application des traductions." + +msgid "sphinx-sitemap" +msgstr "sphinx-sitemap" + +msgid "Sitemap generator for Sphinx" +msgstr "Générateur de plan de site pour Sphinx" + +msgid "myst_parser" +msgstr "myst_parser" + +msgid "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," +msgstr "Un analyseur étendu conforme à [CommonMark](https://spec.commonmark.org/)," + +msgid "myst-nb" +msgstr "myst-nb" + +msgid "A Jupyter Notebook Sphinx reader built on top of the MyST markdown parser." +msgstr "Un lecteur Sphinx de Jupyter Notebook construit au-dessus de l'analyseur Markdown MyST." + +msgid "sphinx_design" +msgstr "sphinx_design" + +msgid "A sphinx extension for designing beautiful, view size responsive web components." +msgstr "Une extension Sphinx pour concevoir de beaux composants web adaptatifs." + +msgid "sphinx_gallery" +msgstr "sphinx_gallery" + +msgid "A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts." +msgstr "Une extension Sphinx qui construit une galerie HTML d'exemples à partir d'un ensemble de scripts Python." + +msgid "sphinx-copybutton" +msgstr "sphinx-copybutton" + +msgid "Add a copy button to each of your code cells." +msgstr "Ajoute un bouton de copie à chacune de vos cellules de code." + +msgid "pydata-sphinx-theme" +msgstr "pydata-sphinx-theme" + +msgid "Bootstrap-based Sphinx theme from the PyData community" +msgstr "Thème Sphinx basé sur Bootstrap de la communauté PyData" + +msgid "Optional modules for running test suite:" +msgstr "Modules optionnels pour l'exécution de la suite de tests :" + +msgid "pytest" +msgstr "pytest" + +msgid "pytest: simple powerful testing with Python" +msgstr "pytest : tests simples et puissants avec Python" + +msgid "pytest-xvfb" +msgstr "pytest-xvfb" + +msgid "A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests." +msgstr "Un plugin pytest pour exécuter Xvfb (ou Xephyr/Xvnc) pour les tests." + diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/lifecycle.po b/doc/locale/fr/LC_MESSAGES/user_guide/lifecycle.po new file mode 100644 index 0000000..67894bc --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/lifecycle.po @@ -0,0 +1,65 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Main-Window Lifecycle" +msgstr "Cycle de vie de la fenêtre principale" + +msgid "SigimaX creates a derived main window in a predictable sequence. Use the protected hooks instead of relying on incidental constructor ordering." +msgstr "SigimaX crée une fenêtre principale dérivée selon une séquence prévisible. Utilisez les points d'extension protégés plutôt que de vous fier à l'ordre accidentel du constructeur." + +msgid "Initialization Order" +msgstr "Ordre d'initialisation" + +msgid "When a :class:`~sigimax.mainwindow.SGMXMainWindow` is created, SigimaX:" +msgstr "Lorsqu'une :class:`~sigimax.mainwindow.SGMXMainWindow` est créée, SigimaX :" + +msgid "initializes its own non-virtual state;" +msgstr "initialise son propre état non virtuel ;" + +msgid "calls ``_before_setup(console)``;" +msgstr "appelle ``_before_setup(console)`` ;" + +msgid "applies the color mode through ``_update_color_mode(startup=True)``;" +msgstr "applique le mode de couleur via ``_update_color_mode(startup=True)`` ;" + +msgid "creates the generic status bar, actions, central widget, menus, and restored window state;" +msgstr "crée la barre d'état générique, les actions, le widget central, les menus et restaure l'état de la fenêtre ;" + +msgid "calls ``_after_setup(console)``; and" +msgstr "appelle ``_after_setup(console)`` ; et" + +msgid "restores the window geometry." +msgstr "restaure la géométrie de la fenêtre." + +msgid "Use ``_before_setup`` for derived state that another protected hook may need during startup. Use ``_after_setup`` for work that requires generic widgets such as actions, menus, or the status bar." +msgstr "Utilisez ``_before_setup`` pour l'état dérivé dont un autre point d'extension protégé pourrait avoir besoin au démarrage. Utilisez ``_after_setup`` pour les travaux nécessitant les widgets génériques tels que les actions, les menus ou la barre d'état." + +msgid "The HDF5 reference application follows this rule by creating its data model in ``_before_setup``:" +msgstr "L'application de référence HDF5 suit cette règle en créant son modèle de données dans ``_before_setup`` :" + +msgid "Singleton Access" +msgstr "Accès au singleton" + +msgid "Call ``MyMainWindow.get_instance()`` on the concrete derived class. It returns the current instance of that class or creates one of the same class. This keeps the base framework from accidentally constructing ``SGMXMainWindow`` when an application requested its own window subclass." +msgstr "Appelez ``MyMainWindow.get_instance()`` sur la classe dérivée concrète. Elle retourne l'instance courante de cette classe ou en crée une de la même classe. Cela évite que le framework de base ne construise accidentellement ``SGMXMainWindow`` alors qu'une application avait demandé sa propre sous-classe de fenêtre." + +msgid "Shutdown Order" +msgstr "Ordre d'arrêt" + +msgid "For a modified workspace, the close flow asks the user to save first. A successful application save clears the modified state; cancellation or failure leaves it set and stops the close. Once closing proceeds, SigimaX calls ``_close_managed_widgets()``, ``_cleanup_before_reset()``, ``reset_all()``, ``_save_pos_size_and_state()``, and ``_cleanup_after_state_save()`` in that order." +msgstr "Pour un espace de travail modifié, le flux de fermeture demande d'abord à l'utilisateur d'enregistrer. Un enregistrement applicatif réussi efface l'état modifié ; une annulation ou un échec le laisse actif et arrête la fermeture. Une fois la fermeture engagée, SigimaX appelle ``_close_managed_widgets()``, ``_cleanup_before_reset()``, ``reset_all()``, ``_save_pos_size_and_state()``, puis ``_cleanup_after_state_save()``, dans cet ordre." + +msgid "Derived applications should use these hooks for their own managed resources and call ``super()`` when preserving the generic behavior is required." +msgstr "Les applications dérivées doivent utiliser ces points d'extension pour leurs propres ressources gérées et appeler ``super()`` lorsque la préservation du comportement générique est requise." + diff --git a/doc/locale/fr/LC_MESSAGES/user_guide/overview.po b/doc/locale/fr/LC_MESSAGES/user_guide/overview.po new file mode 100644 index 0000000..6e516f5 --- /dev/null +++ b/doc/locale/fr/LC_MESSAGES/user_guide/overview.po @@ -0,0 +1,251 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, DataLab Platform Developers +# This file is distributed under the same license as the SigimaX package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "Overview" +msgstr "Vue d'ensemble" + +msgid "What is SigimaX?" +msgstr "Qu'est-ce que SigimaX ?" + +msgid "**SigimaX** is an open-source Python framework for building Qt-based scientific desktop applications. It provides a reusable application skeleton — main window, configuration system, embedded widgets, and HDF5 infrastructure — so that developers can focus on domain-specific features instead of boilerplate." +msgstr "**SigimaX** est un framework Python open-source pour la construction d'applications de bureau scientifiques basées sur Qt. Il fournit un squelette d'application réutilisable — fenêtre principale, système de configuration, widgets intégrés et infrastructure HDF5 — afin que les développeurs puissent se concentrer sur les fonctionnalités spécifiques à leur domaine plutôt que sur le code répétitif." + +msgid "SigimaX is extracted from `DataLab `_, a mature open-source platform for scientific signal and image processing, and powers its GUI layer." +msgstr "SigimaX est extrait de `DataLab `_, une plateforme open-source mature pour le traitement scientifique de signaux et d'images, et alimente sa couche d'interface graphique." + +msgid "Why SigimaX?" +msgstr "Pourquoi SigimaX ?" + +msgid "Building a scientific desktop application from scratch typically requires:" +msgstr "Construire une application de bureau scientifique à partir de zéro nécessite généralement :" + +msgid "A main window with menus, toolbars, dock widgets, and status bar" +msgstr "Une fenêtre principale avec des menus, des barres d'outils, des widgets ancrables et une barre d'état" + +msgid "A configuration system with typed options, persistence, and defaults" +msgstr "Un système de configuration avec des options typées, de la persistance et des valeurs par défaut" + +msgid "HDF5 file management (industry standard for scientific data)" +msgstr "La gestion de fichiers HDF5 (standard industriel pour les données scientifiques)" + +msgid "An embedded Python console for scripting and debugging" +msgstr "Une console Python intégrée pour le scripting et le débogage" + +msgid "Specialized dialogs for signal/image analysis (fitting, peak detection, etc.)" +msgstr "Des dialogues spécialisés pour l'analyse de signaux/images (ajustement, détection de pics, etc.)" + +msgid "Production-grade features: splash screen, memory monitoring, log viewer" +msgstr "Des fonctionnalités de qualité production : écran de démarrage, surveillance mémoire, visualiseur de journaux" + +msgid "SigimaX provides all of these as a **subclassable framework**, so derived applications only need to add their domain-specific logic." +msgstr "SigimaX fournit tout cela sous forme d'un **framework dérivable par sous-classement**, de sorte que les applications dérivées n'ont qu'à ajouter leur logique spécifique au domaine." + +msgid "Position in the Stack" +msgstr "Position dans la pile logicielle" + +msgid "SigimaX sits between the low-level libraries (PlotPy, guidata, PythonQwt) and end-user applications:" +msgstr "SigimaX se situe entre les bibliothèques de bas niveau (PlotPy, guidata, PythonQwt) et les applications utilisateur :" + +msgid "Layer" +msgstr "Couche" + +msgid "Project" +msgstr "Projet" + +msgid "Role" +msgstr "Rôle" + +msgid "End-user apps" +msgstr "Applications utilisateur" + +msgid "DataLab" +msgstr "DataLab" + +msgid "Signal/image processing GUI application" +msgstr "Application d'interface graphique de traitement de signaux et d'images" + +msgid "GUI framework" +msgstr "Framework d'interface graphique" + +msgid "**SigimaX**" +msgstr "**SigimaX**" + +msgid "Reusable application skeleton" +msgstr "Squelette d'application réutilisable" + +msgid "Computation" +msgstr "Calcul" + +msgid "Sigima" +msgstr "Sigima" + +msgid "Headless scientific computing (signals & images)" +msgstr "Calcul scientifique sans interface graphique (signaux et images)" + +msgid "Plotting" +msgstr "Tracé" + +msgid "PlotPy + PythonQwt" +msgstr "PlotPy + PythonQwt" + +msgid "Interactive plot widgets" +msgstr "Widgets de tracé interactifs" + +msgid "GUI toolkit" +msgstr "Boîte à outils d'interface graphique" + +msgid "guidata" +msgstr "guidata" + +msgid "Dataset/parameter framework with automatic GUI generation" +msgstr "Framework de jeux de données/paramètres avec génération automatique d'interface graphique" + +msgid "Foundation" +msgstr "Fondation" + +msgid "NumPy + SciPy + Qt" +msgstr "NumPy + SciPy + Qt" + +msgid "Core scientific and GUI libraries" +msgstr "Bibliothèques scientifiques et d'interface graphique fondamentales" + +msgid "Architecture" +msgstr "Architecture" + +msgid "Core Modules" +msgstr "Modules principaux" + +msgid "Module" +msgstr "Module" + +msgid "Purpose" +msgstr "Fonction" + +msgid ":mod:`sigimax.app`" +msgstr ":mod:`sigimax.app`" + +msgid "Application launcher — ``create()`` and ``run()`` functions" +msgstr "Lanceur d'application — fonctions ``create()`` et ``run()``" + +msgid ":mod:`sigimax.config`" +msgstr ":mod:`sigimax.config`" + +msgid "Configuration system — ``SigimaXOptions``, ``CONF`` singleton, option fields" +msgstr "Système de configuration — ``SigimaXOptions``, singleton ``CONF``, champs d'options" + +msgid ":mod:`sigimax.env`" +msgstr ":mod:`sigimax.env`" + +msgid "Runtime environment — ``SGMXExecEnv``, verbosity levels, unattended mode" +msgstr "Environnement d'exécution — ``SGMXExecEnv``, niveaux de verbosité, mode non interactif" + +msgid ":mod:`sigimax.mainwindow`" +msgstr ":mod:`sigimax.mainwindow`" + +msgid "``SGMXMainWindow`` — generic main window with menus, console, HDF5 workspace" +msgstr "``SGMXMainWindow`` — fenêtre principale générique avec menus, console, espace de travail HDF5" + +msgid "Design Philosophy" +msgstr "Philosophie de conception" + +msgid "SigimaX separates the **generic application skeleton** from **domain-specific logic**. Derived applications follow a three-step pattern:" +msgstr "SigimaX s\\u00e9pare le **squelette g\\u00e9n\\u00e9rique de l'application** de la **logique sp\\u00e9cifique au domaine**. Les applications d\\u00e9riv\\u00e9es suivent un processus en trois \\u00e9tapes :" + +msgid "**Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific configuration fields" +msgstr "**Sous-classer** :class:`~sigimax.config.SigimaXOptions` pour ajouter des champs de configuration sp\\u00e9cifiques \\u00e0 l'application" + +msgid "**Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize menus, toolbars, and dock widgets" +msgstr "**Sous-classer** :class:`~sigimax.mainwindow.SGMXMainWindow` pour personnaliser les menus, les barres d'outils et les widgets ancrables" + +msgid "**Call** :func:`~sigimax.app.run` to launch the application with splash screen support" +msgstr "**Appeler** :func:`~sigimax.app.run` pour lancer l'application avec le support de l'\\u00e9cran de d\\u00e9marrage" + +msgid "This architecture is proven in production: `DataLab `_ is built entirely on this pattern." +msgstr "Cette architecture est \\u00e9prouv\\u00e9e en production : `DataLab `_ est enti\\u00e8rement construit sur ce mod\\u00e8le." + +msgid "Configuration System" +msgstr "Syst\\u00e8me de configuration" + +msgid "The configuration system uses typed option fields that support:" +msgstr "Le syst\\u00e8me de configuration utilise des champs d'options typ\\u00e9s qui prennent en charge :" + +msgid "**Type safety**: ``TypedOptionField`` (int, str, bool, float), ``EnumOptionField`` (constrained choices), ``TupleOptionField`` (fixed-length tuples), ``FontOptionField`` (font validation)" +msgstr "**S\\u00e9curit\\u00e9 de typage** : ``TypedOptionField`` (int, str, bool, float), ``EnumOptionField`` (choix contraints), ``TupleOptionField`` (tuples de longueur fixe), ``FontOptionField`` (validation de police)" + +msgid "**Persistence**: JSON save/load with ``save()`` and ``load()`` methods" +msgstr "**Persistance** : sauvegarde/chargement JSON avec les m\\u00e9thodes ``save()`` et ``load()``" + +msgid "**Context managers**: Temporary overrides with ``option.context(value)``" +msgstr "**Gestionnaires de contexte** : surcharges temporaires avec ``option.context(value)``" + +msgid "**Defaults**: Built-in reset-to-defaults mechanism" +msgstr "**Valeurs par d\\u00e9faut** : m\\u00e9canisme int\\u00e9gr\\u00e9 de r\\u00e9initialisation aux valeurs par d\\u00e9faut" + +msgid "What SigimaX Provides vs What Stays in Derived Apps" +msgstr "Ce que SigimaX fournit vs ce qui reste dans les applications dérivées" + +msgid "**In SigimaX**" +msgstr "**Dans SigimaX**" + +msgid "**Stays in derived apps (e.g. DataLab)**" +msgstr "**Reste dans les applications dérivées (par ex. DataLab)**" + +msgid "Configuration system" +msgstr "Système de configuration" + +msgid "Signal/Image panels, processors" +msgstr "Panneaux Signal/Image, processeurs" + +msgid "Generic main window" +msgstr "Fenêtre principale générique" + +msgid "Action handler, plugin system" +msgstr "Gestionnaire d'actions, système de plugins" + +msgid "Dockable plot widgets" +msgstr "Widgets de tracé ancrables" + +msgid "Remote control (XML-RPC, Web API)" +msgstr "Contrôle à distance (XML-RPC, API Web)" + +msgid "HDF5 I/O + browser" +msgstr "E/S HDF5 + navigateur" + +msgid "Macro editor, new-object dialogs" +msgstr "Éditeur de macros, dialogues de création d'objets" + +msgid "Scientific dialogs (fit, baseline, peak, cursor…)" +msgstr "Dialogues scientifiques (ajustement, ligne de base, pic, curseur…)" + +msgid "Application-specific UI and processing" +msgstr "Interface utilisateur et traitements spécifiques à l'application" + +msgid "Log viewer, status bar, splash screen, wizard" +msgstr "Visualiseur de journaux, barre d'état, écran de démarrage, assistant" + +msgid "Object model, plot handler" +msgstr "Modèle d'objets, gestionnaire de tracé" + +msgid "PlotPy adapters" +msgstr "Adaptateurs PlotPy" + +msgid "Processor registration pattern" +msgstr "Patron d'enregistrement des processeurs" + +msgid "Environment/exec utilities" +msgstr "Utilitaires d'environnement/exécution" + +msgid "Tour/tutorial features" +msgstr "Fonctionnalités de visite guidée/tutoriel" + diff --git a/doc/release_notes/index.rst b/doc/release_notes/index.rst new file mode 100644 index 0000000..ed85330 --- /dev/null +++ b/doc/release_notes/index.rst @@ -0,0 +1,12 @@ +Release notes +============= + +This section contains the release notes for all versions of :mod:`sigimax`, documenting +new features, improvements, bug fixes, and breaking changes. + +.. toctree:: + :maxdepth: 1 + :glob: + :reversed: + + release_* diff --git a/doc/release_notes/release_0.01.md b/doc/release_notes/release_0.01.md new file mode 100644 index 0000000..44afd02 --- /dev/null +++ b/doc/release_notes/release_0.01.md @@ -0,0 +1,138 @@ +# Version 0.1 # + +## SigimaX Version 0.1.0 (2026-03-04) ## + +Initial development release — SigimaX is extracted from DataLab as a reusable GUI +application framework for scientific computing Qt applications. + +### Highlights ### + +SigimaX provides the generic "application skeleton" that any scientific computing Qt +application can build upon by subclassing its main window and configuration system. +This release contains the full extraction from DataLab, including: + +* Generic main window (`SGMXMainWindow`) with customizable menus, toolbars, and console +* Configuration system based on Sigima's typed option fields +* HDF5 browsing, generic import, and workspace persistence hooks +* Splash screen and application launcher (`create()` / `run()`) +* PlotPy adapters for signal/image plot items +* Reusable scientific widgets (fit dialogs, baseline, peak detection, cursor, etc.) +* Comprehensive test suite with 155+ tests across unit, GUI, and app categories +* Full Sphinx documentation with API reference and gallery examples +* Complete French translation (189 strings) + +### Application framework ### + +* Implemented `SGMXMainWindow` — a generic main window that derived applications + subclass to build their own UI, with customizable menu order, toolbar actions, + and console namespace +* Added `create()` and `run()` application launcher functions in `app.py` with + configurable splash screen support (`SplashScreenConfig`) +* Provided overridable hooks for derived apps: `reset_all()`, `_is_save_enabled()`, + `_update_file_menu()`, `_update_view_menu()`, `_about()` +* Added generic main toolbar configuration — derived apps can redefine toolbar actions +* Added quit action to the file menu + +### Configuration system ### + +* Implemented typed configuration options inspired by Sigima's non-INI-file config + system, with `TypedOptionField`, `EnumOptionField`, `TupleOptionField`, and + `FontOptionField` +* Added generic application metadata options (`app_name`, `app_version`, + `app_logo_path`, `app_desc`, `app_docurl`, `app_homeurl`, `app_supporturl`) +* Configuration supports JSON persistence via `save()` / `load()` +* Removed `process_isolation_enabled` option (DataLab-specific — the framework only + used it cosmetically; the actual mechanism stays in DataLab) + +### HDF5 support ### + +* Ported generic HDF5 browsing and dataset import +* Added workspace persistence hooks for derived applications; SigimaX does not + impose a universal workspace format or serializer +* Added `import_dataset_from_file()` hook for derived apps to handle + application-specific dataset import from HDF5 +* Ported `H5BrowserDialog` widget for interactive HDF5 file browsing + +### Widgets ### + +* Ported scientific dialog widgets from DataLab: curve fitting (`fitdialog`), + signal baseline selection, signal peak detection, signal cursor, signal delta-X + measurement, image background selection +* Added `DockablePlotWidget` for embedding PlotPy plots in dock widgets, with + configurable watermark and dock location via `SigimaXOptions` +* Ported status bar widgets: `BaseStatus`, `MemoryStatus`, `ConsoleStatus` +* Added `Wizard` multi-page dialog widget +* Added `LogViewerWindow` for log display +* Added `FileViewerWidget` for read-only file viewing +* Added `WarningErrorMessageBox` for warning/error display +* Added convenience re-exports in `widgets/__init__.py` with `__all__` + +### PlotPy adapters ### + +* Ported `adapters_plotpy` module for converting between Sigima objects + (`SignalObj`, `ImageObj`) and PlotPy plot items +* Added `iterate_metadata_shape_items()` hook on `BaseObjPlotPyAdapter` — + a no-op generator that derived apps (DataLab) can override to yield plot items + for app-specific metadata entries (geometry results, table results) +* Cleaned up commented-out scalar adapter code (`GeometryPlotPyAdapter`, + `TableAdapter`) — these stay in DataLab + +### Environment and utilities ### + +* Implemented `SGMXExecEnv` runtime environment singleton (renamed from DataLab's + `DLExecEnv`) with verbosity levels, demo mode, and unattended mode +* Ported Qt helper utilities (`utils/qthelpers.py`): log file management, + signal blocking context manager, stdout/stderr save/restore +* Added local PDF documentation path handling in the Help menu + +### Package structure ### + +* Dissolved `gui/` subpackage — moved `gui/main.py` → `mainwindow.py` (top-level) + and `gui/docks.py` → `widgets/plotdock.py` for clearer naming +* Added `__all__` declarations in all public modules +* Added `from __future__ import annotations` across all modules +* Top-level `__init__.py` re-exports `SGMXMainWindow`, `create`, `run` + (follows Sigima's pattern) +* Fixed circular import between `__init__.py` and `config.py` by extracting + metadata to `_metadata.py` +* Removed dead code: commented-out imports, `config_old.py`, DataLab-specific + action handler dependencies + +### Testing ### + +* Built comprehensive test suite with 155+ tests organized into subpackages: + `config/`, `mainwindow/`, `widgets/`, `hdf5/`, `adapters_plotpy/`, `utils/` +* Standardized test file naming to `test_*.py` convention; non-test helpers + prefixed with `_` +* Added pytest markers: `@pytest.mark.unit` (pure logic, no Qt), + `@pytest.mark.gui` (Qt widget tests), `@pytest.mark.app` (full main window) +* Added `--show-windows` flag for visual test validation (offscreen by default) +* PlotPy adapter tests cover factory dispatch, make/update item roundtrips, + ROI coordinate roundtrips, and annotation integration + +### Documentation ### + +* Added full Sphinx documentation with API reference pages for all public modules + (`app`, `config`, `env`, `mainwindow`, `widgets`, `h5`, `adapters_plotpy`, `utils`) +* Added Sphinx-Gallery examples: getting started (`minimal_app.py`), features + (`configuration.py`, `plot_widget.py`), and use cases (`full_app.py`) +* Added user guide pages: overview, installation, contributing +* Documentation builds cleanly with `-W` (warnings-as-errors) + +### Internationalization ### + +* Added complete French translation of all 189 UI strings + (`sigimax/locale/fr/LC_MESSAGES/sigimax.po`) +* Translations cover menus, toolbar, HDF5 browser, fit dialogs, signal widgets, + status bar, error/warning dialogs, wizard, and about/help +* Terminology aligned with DataLab's existing French translations for consistency + +### Project infrastructure ### + +* Created `pyproject.toml` with Ruff rules (`D202`, `D403`, `RUF022`, Google + pydocstyle), pytest config (`--import-mode=importlib`, `filterwarnings`) +* Added Sphinx documentation scaffolding (imported from Sigima's structure) +* Added copilot instructions (`.github/copilot-instructions.md`) +* Changed maintainer email to `datalab@codra.fr` +* Fixed `run_with_env.py` to substitute `sys.executable` when command starts + with `python`, ensuring the correct venv interpreter is used diff --git a/doc/requirements.rst b/doc/requirements.rst new file mode 100644 index 0000000..3494d39 --- /dev/null +++ b/doc/requirements.rst @@ -0,0 +1,109 @@ +The `sigimax` package requires the following Python modules: + +.. list-table:: + :header-rows: 1 + :align: left + + * - Name + - Version + - Summary + * - Python + - >=3.9, <4 + - Python programming language + * - guidata + - >= 3.13.4 + - Automatic GUI generation for easy dataset editing and display + * - PlotPy + - >= 2.8.2 + - Curve and image plotting tools for Python/Qt applications + * - psutil + - >= 5.7 + - Cross-platform lib for process and system monitoring. + * - Sigima + - >= 1.1.0 + - Scientific computing engine for 1D signals and 2D images, part of the DataLab open-source platform. + +Optional modules for GUI support (Qt): + +.. list-table:: + :header-rows: 1 + :align: left + + * - Name + - Version + - Summary + * - PyQt5 + - >= 5.15.6 + - Python bindings for the Qt cross platform application toolkit + +Optional modules for development: + +.. list-table:: + :header-rows: 1 + :align: left + + * - Name + - Version + - Summary + * - ruff + - + - An extremely fast Python linter and code formatter, written in Rust. + * - pylint + - + - python code static checker + * - Coverage + - + - Code coverage measurement for Python + +Optional modules for building the documentation: + +.. list-table:: + :header-rows: 1 + :align: left + + * - Name + - Version + - Summary + * - sphinx + - + - Python documentation generator + * - sphinx_intl + - + - Sphinx utility that make it easy to translate and to apply translation. + * - sphinx-sitemap + - + - Sitemap generator for Sphinx + * - myst_parser + - + - An extended [CommonMark](https://spec.commonmark.org/) compliant parser, + * - myst-nb + - + - A Jupyter Notebook Sphinx reader built on top of the MyST markdown parser. + * - sphinx_design + - + - A sphinx extension for designing beautiful, view size responsive web components. + * - sphinx_gallery + - + - A Sphinx extension that builds an HTML gallery of examples from any set of Python scripts. + * - sphinx-copybutton + - + - Add a copy button to each of your code cells. + * - pydata-sphinx-theme + - + - Bootstrap-based Sphinx theme from the PyData community + +Optional modules for running test suite: + +.. list-table:: + :header-rows: 1 + :align: left + + * - Name + - Version + - Summary + * - pytest + - + - pytest: simple powerful testing with Python + * - pytest-xvfb + - + - A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests. \ No newline at end of file diff --git a/doc/user_guide/features.rst b/doc/user_guide/features.rst new file mode 100644 index 0000000..010717c --- /dev/null +++ b/doc/user_guide/features.rst @@ -0,0 +1,192 @@ +.. _features: + +Features +======== + +This page provides an organized catalog of SigimaX's key features. + +Application Framework +--------------------- + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Feature + - Description + * - :class:`~sigimax.mainwindow.SGMXMainWindow` + - Generic main window with customizable menus, toolbars, console, and dock + widgets. Derived apps subclass this to build their own UI. + * - :func:`~sigimax.app.create` + - Instantiate a main window with optional splash screen, console, and size. + Returns the window instance for embedding. + * - :func:`~sigimax.app.run` + - Create the window and enter the Qt event loop. The standard entry point + for standalone applications. + * - :class:`~sigimax.widgets.splashscreen.SplashScreenConfig` + - Configurable splash screen with image, app name, version, tagline, and + optional progress display. + +Configuration System +-------------------- + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Feature + - Description + * - :class:`~sigimax.config.SigimaXOptions` + - Full configuration singleton (``CONF``) with 20+ typed options covering + app metadata, plot defaults, HDF5 settings, and more. + * - ``EnumOptionField`` + - Option constrained to a set of string choices, with validation. + * - ``TupleOptionField`` + - Fixed-length tuple option with type checking. + * - ``FontOptionField`` + - Font option with validation against available system fonts. + * - JSON persistence + - ``save()`` / ``load()`` methods for configuration persistence. + * - Context managers + - Temporary overrides with ``option.context(value)`` pattern. + +.. admonition:: Configuration options reference + + The full list of configuration options available in the ``CONF`` singleton: + + .. options-table:: + +HDF5 Workspace +-------------- + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Feature + - Description + * - Open/Save workspace + - File actions and workspace-state handling. Derived applications provide + their own workspace serialization. + * - :class:`~sigimax.widgets.h5browser.H5BrowserDialog` + - Interactive HDF5 file browser with tree view, supporting scalar, array, + text, and compound datasets. + * - Import datasets + - ``import_dataset_from_file()`` hook for derived apps to handle + application-specific HDF5 dataset import. + * - :class:`~sigimax.h5.H5Importer` + - Low-level HDF5 import utilities with node factory and data extraction. + +See :doc:`hdf5_workspace` for the persistence contract and its complete +derived-application example. + +Scientific Widgets +------------------ + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Widget + - Description + * - :class:`~sigimax.widgets.plotdock.DockablePlotWidget` + - Embeds PlotPy plots in dock widgets, with configurable watermark and + dock location. Supports ``PlotType.CURVE`` and ``PlotType.IMAGE``. + * - Curve fitting dialogs + - Gaussian, polynomial, and custom curve fitting via + :mod:`sigimax.widgets.fitdialog`. + * - Signal peak detection + - Interactive peak detection dialog via + :mod:`sigimax.widgets.signalpeak`. + * - Signal baseline selection + - Baseline selection for background subtraction via + :mod:`sigimax.widgets.signalbaseline`. + * - Signal cursor + - Cursor-based value readout via + :mod:`sigimax.widgets.signalcursor`. + * - Signal delta-X + - Delta-X measurement between two points via + :mod:`sigimax.widgets.signaldeltax`. + * - Image background + - Image background region selection via + :mod:`sigimax.widgets.imagebackground`. + * - :class:`~sigimax.widgets.wizard.Wizard` + - Multi-page wizard dialog with navigation, validation, and data + collection (Next/Back/Finish/Cancel). + * - :class:`~sigimax.widgets.logviewer.LogViewerWindow` + - Log viewer dialog for displaying application log files. + * - :class:`~sigimax.widgets.warningerror.WarningErrorMessageBox` + - Warning/error display dialog with traceback support. + +Status Bar Widgets +------------------ + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Widget + - Description + * - :class:`~sigimax.widgets.status.MemoryStatus` + - Displays current memory usage with configurable alarm threshold. + * - :class:`~sigimax.widgets.status.ConsoleStatus` + - Console toggle button in the status bar. + * - :class:`~sigimax.widgets.status.BaseStatus` + - Base status bar widget for custom status indicators. + +PlotPy Adapters +--------------- + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Feature + - Description + * - Signal/Image adapters + - Convert between Sigima objects (:class:`~sigima.objects.SignalObj`, + :class:`~sigima.objects.ImageObj`) and PlotPy plot items for display. + * - ROI adapters + - Convert between Sigima ROI objects and PlotPy annotation items + (segment, rectangular, circular, polygonal). + * - :func:`~sigimax.adapters_plotpy.create_adapter_from_object` + - Factory function that dispatches to the correct adapter based on + object type. + * - JSON roundtrips + - ``items_to_json()`` / ``json_to_items()`` for serializing plot items. + +Runtime Environment +------------------- + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Feature + - Description + * - :class:`~sigimax.env.SGMXExecEnv` + - Runtime environment singleton (``execenv``) controlling unattended mode, + verbosity levels, demo mode, screenshot capture, and delay settings. + * - :class:`~sigimax.env.VerbosityLevels` + - Enum with ``quiet``, ``normal``, and ``debug`` verbosity levels. + * - Command-line arguments + - ``--unattended``, ``--verbose``, ``--screenshot``, ``--delay``, + ``--version``, ``--reset`` parsed automatically on startup. + * - Context manager + - ``execenv.context()`` for temporarily overriding environment settings. + +Internationalization +-------------------- + +SigimaX supports internationalization with gettext: + +- All UI strings are wrapped with ``_()`` from :mod:`sigimax.config` +- Translations are stored in ``locale/`` (currently English and French) +- Use ``guidata.utils.translations`` CLI to scan and compile translations diff --git a/doc/user_guide/getting_started.rst b/doc/user_guide/getting_started.rst new file mode 100644 index 0000000..0deeaed --- /dev/null +++ b/doc/user_guide/getting_started.rst @@ -0,0 +1,130 @@ +.. _getting_started: + +Getting Started +=============== + +This page provides a quick introduction to building applications with SigimaX. + +SigimaX follows a **three-step derivation pattern**: subclass the configuration, +subclass the main window, and launch with ``run()``. This pattern is proven in +production — `DataLab `_ is built entirely on it. + +The Derivation Pattern +---------------------- + +**Step 1 — Subclass** :class:`~sigimax.config.SigimaXOptions` to add +application-specific configuration fields: + +.. code-block:: python + + from sigimax.config import TypedOptionField + from sigimax.config import EnumOptionField, SigimaXOptions + + class MyAppOptions(SigimaXOptions): + def __init__(self): + super().__init__() + self.app_name.set("MyApp") + self.greeting = TypedOptionField( + self, "greeting", default="Hello!", + expected_type=str, description="Startup message", + ) + self.unit_system = EnumOptionField( + self, "unit_system", default="metric", + choices=["metric", "imperial"], + description="Default units", + ) + +**Step 2 — Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize +the user interface: + +.. code-block:: python + + from sigimax.config import CONF as Conf, _ + from sigimax.mainwindow import SGMXMainWindow + from sigimax.widgets.plotdock import DockablePlotWidget + from plotpy.constants import PlotType + + class MyAppMainWindow(SGMXMainWindow): + def __init__(self, console=None, hide_on_close=False): + Conf.app_name.set("MyApp") + super().__init__(console=console, hide_on_close=hide_on_close) + # Add a dockable curve plot + self.curve_dock = DockablePlotWidget(self, PlotType.CURVE) + dock, loc = self.curve_dock.create_dockwidget(_("Curve Viewer")) + self.addDockWidget(loc, dock) + +**Step 3 — Launch** the application: + +.. code-block:: python + + from sigimax.app import run + + run(window_class=MyAppMainWindow) + +That's it. Three classes, three steps — and you have a full-featured scientific +desktop application with menus, toolbars, console, HDF5 workspace, and status bar. + +Key Concepts +^^^^^^^^^^^^ + +- **Configuration system**: Options are typed fields (``TypedOptionField``, + ``EnumOptionField``, ``TupleOptionField``) that support ``get()``/``set()``/ + ``context()`` API and JSON persistence. + +- **Overridable hooks**: The main window provides hooks that derived apps can + override: ``reset_all()``, ``_is_save_enabled()``, ``_update_file_menu()``, + ``_update_view_menu()``, ``_about()``, ``_before_setup()``, and + ``_after_setup()``. See :doc:`lifecycle` for their ordering and + initialization contract. + +- **Built-in features**: HDF5 GUI and generic dataset import; derived + applications implement their own workspace persistence. SigimaX also + provides an embedded Python console, status bar with memory monitoring, and + splash screen support. See :doc:`hdf5_workspace` for the derived-application + contract. + +What's Included +--------------- + +SigimaX provides 15+ ready-to-use scientific widgets: + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 30 70 + + * - Widget + - Purpose + * - :class:`~sigimax.widgets.plotdock.DockablePlotWidget` + - Embeddable PlotPy plot in dock widgets + * - :class:`~sigimax.widgets.h5browser.H5BrowserDialog` + - Interactive HDF5 file browser + * - :class:`~sigimax.widgets.fitdialog` + - Curve fitting dialogs (Gaussian, polynomial, etc.) + * - :class:`~sigimax.widgets.signalpeak` + - Signal peak detection dialog + * - :class:`~sigimax.widgets.signalbaseline` + - Signal baseline selection dialog + * - :class:`~sigimax.widgets.signalcursor` + - Signal cursor selection dialog + * - :class:`~sigimax.widgets.signaldeltax` + - Signal delta-X measurement dialog + * - :class:`~sigimax.widgets.imagebackground` + - Image background selection dialog + * - :class:`~sigimax.widgets.logviewer.LogViewerWindow` + - Log viewer dialog + * - :class:`~sigimax.widgets.wizard.Wizard` + - Multi-page wizard dialog + * - :class:`~sigimax.widgets.splashscreen.SigimaXSplashScreen` + - Configurable splash screen + * - :class:`~sigimax.widgets.status.MemoryStatus` + - Status bar memory usage widget + * - :class:`~sigimax.widgets.warningerror.WarningErrorMessageBox` + - Warning/error display dialog + +Next Steps +---------- + +- Browse the :doc:`../auto_examples/index` to see SigimaX in action +- Read the :doc:`overview` for architecture details +- Dive into the :doc:`/api/index` for complete reference documentation diff --git a/doc/user_guide/hdf5_workspace.rst b/doc/user_guide/hdf5_workspace.rst new file mode 100644 index 0000000..0b207f1 --- /dev/null +++ b/doc/user_guide/hdf5_workspace.rst @@ -0,0 +1,90 @@ +HDF5 Workspace +============== + +SigimaX provides the HDF5 user interface and generic dataset import. A derived +application owns its workspace model and its workspace file format. SigimaX +does not define a universal workspace schema. + +Responsibilities +---------------- + +SigimaX provides: + +- File, Open/Import, Browse, and Save actions; +- the HDF5 browser and :class:`sigimax.h5.H5Importer` for arbitrary datasets; +- :meth:`sigimax.mainwindow.SGMXMainWindow.set_modified`, the save prompt, and + the close workflow; +- protected HDF5 hooks for derived applications. + +A derived application provides: + +- its data model; +- workspace serialization and deserialization; +- the mutation points that mark the workspace modified; +- optional application-specific dataset import. + +A base :class:`~sigimax.mainwindow.SGMXMainWindow` has no data model. Its Save +action remains disabled and a direct call to ``save_h5_workspace`` raises +:class:`NotImplementedError`. This prevents a successful-looking save from +silently discarding a modified workspace. + +Minimal Derived Application +--------------------------- + +The SigimaX test suite contains an executable reference application. It is both +an integration test and a complete minimal example: it stores signals and +images, imports generic HDF5 datasets, and writes a small workspace format. + +Its data model deliberately remains application code. It uses two collections +and knows how to serialize them with ``guidata``: + +.. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py + :language: python + :pyobject: SimpleObjectStore + +The derived window validates paths through the protected hook, writes its model, +and only clears the modified flag after a successful write: + +.. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py + :language: python + :pyobject: DerivedAppWindow.save_h5_workspace + +The matching loader is equally application-specific: + +.. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py + :language: python + :pyobject: DerivedAppWindow.load_h5_workspace + +Workspace State and Save +------------------------ + +Call ``set_modified(True)`` whenever an application mutation changes the +workspace. The framework adds an asterisk to the window title, enables Save for +windows that implement ``save_h5_workspace``, and asks for confirmation when +the user closes a modified window. + +A successful ``save_h5_workspace`` implementation must call +``set_modified(False)`` only after its writer closes without an exception. An +exception or a cancelled save leaves the workspace modified, so the close flow +remains safe. + +Generic Dataset Import +---------------------- + +``open_h5_files`` and :class:`~sigimax.widgets.h5browser.H5BrowserDialog` are +for importing arbitrary HDF5 datasets. They are independent from an +application's workspace loader. The reference application connects +``SIG_SEND_OBJECTLIST`` to its model and implements +``import_dataset_from_file`` for programmatic selection of a dataset. + +The generic import layer can create :class:`sigima.objects.SignalObj` and +:class:`sigima.objects.ImageObj`; a derived application decides how these +objects enter its own model. + +DataLab +------- + +DataLab keeps its native workspace format, panel serialization, metadata, ROI, +and analysis-result handling. Its ``save_h5_workspace`` override remains the +owner of that format. The SigimaX HDF5 GUI and generic import infrastructure do +not change DataLab's native HDF5 layout. diff --git a/doc/user_guide/index.rst b/doc/user_guide/index.rst new file mode 100644 index 0000000..1ea6113 --- /dev/null +++ b/doc/user_guide/index.rst @@ -0,0 +1,42 @@ +User Guide +========== + +.. only:: html and not latex + + .. grid:: 2 2 4 4 + :gutter: 1 2 3 4 + + .. grid-item-card:: :octicon:`download;1em;sd-text-info` Installation + :link: installation + :link-type: doc + + How to install SigimaX + + .. grid-item-card:: :octicon:`rocket;1em;sd-text-info` Getting Started + :link: getting_started + :link-type: doc + + Build your first app in minutes + + .. grid-item-card:: :octicon:`book;1em;sd-text-info` Overview + :link: overview + :link-type: doc + + Architecture and design philosophy + + .. grid-item-card:: :octicon:`star;1em;sd-text-info` Features + :link: features + :link-type: doc + + Key functionalities + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + getting_started + hdf5_workspace + lifecycle + overview + features diff --git a/doc/user_guide/installation.rst b/doc/user_guide/installation.rst new file mode 100644 index 0000000..ff81d29 --- /dev/null +++ b/doc/user_guide/installation.rst @@ -0,0 +1,116 @@ +.. _installation: + +Installation +============ + +This section provides instructions on how to install and set up SigimaX, +including dependencies and environment configuration. + +How to install +-------------- + +SigimaX is available in several forms: + +- As a Python package, which can be installed using the :ref:`install_pip`. + +- As a precompiled :ref:`install_wheel`, which can be installed using ``pip``. + +- As a :ref:`install_source`, which can be installed from the Git repository. + +.. seealso:: + + Impatient to try the next version of SigimaX? You can also install the + latest development version from the main branch of the Git repository. + See :ref:`install_development` for more information. + +.. _install_pip: + +Package manager ``pip`` +^^^^^^^^^^^^^^^^^^^^^^^ + +:octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` + +SigimaX's package ``sigimax`` is available on the Python Package Index (PyPI) +at: https://pypi.python.org/pypi/sigimax. + +Install SigimaX by running: + +.. code-block:: console + + $ pip install sigimax + +.. note:: + + If you already have a previous version of SigimaX installed, you can + upgrade it by running the same command with the ``--upgrade`` option: + + .. code-block:: console + + $ pip install --upgrade sigimax + +.. _install_wheel: + +Wheel package +^^^^^^^^^^^^^ + +:octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` + +On any operating system, using pip and the Wheel package is the easiest way to +install SigimaX on an existing Python distribution: + +.. code-block:: console + + $ pip install --upgrade sigimax-0.1.0-py2.py3-none-any.whl + +.. _install_source: + +Source package +^^^^^^^^^^^^^^ + +:octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` + +Installing SigimaX directly from the source package may be done using ``pip``: + +.. code-block:: console + + $ pip install --upgrade sigimax-0.1.0.tar.gz + +Or, if you prefer, you can install it in editable mode from the root directory +of the source package: + +.. code-block:: console + + $ pip install -e . + +.. _install_development: + +Development version +^^^^^^^^^^^^^^^^^^^ + +:octicon:`info;1em;sd-text-info` :bdg-info-line:`GNU/Linux` :bdg-info-line:`Windows` :bdg-info-line:`macOS` + +If you want to try the latest development version of SigimaX, you can install +it directly from the main branch of the Git repository. + +The first time you install SigimaX from the Git repository, enter the following +command: + +.. code-block:: console + + $ pip install git+https://github.com/DataLab-Platform/SigimaX.git + +Then, if at some point you want to upgrade to the latest version, run: + +.. code-block:: console + + $ pip install --force-reinstall --no-deps git+https://github.com/DataLab-Platform/SigimaX.git + +.. note:: + + If dependencies have changed, you may need to execute the same command + without the ``--no-deps`` option. + +Dependencies +------------ + +.. include:: ../requirements.rst diff --git a/doc/user_guide/lifecycle.rst b/doc/user_guide/lifecycle.rst new file mode 100644 index 0000000..4e9ce94 --- /dev/null +++ b/doc/user_guide/lifecycle.rst @@ -0,0 +1,50 @@ +Main-Window Lifecycle +===================== + +SigimaX creates a derived main window in a predictable sequence. Use the +protected hooks instead of relying on incidental constructor ordering. + +Initialization Order +-------------------- + +When a :class:`~sigimax.mainwindow.SGMXMainWindow` is created, SigimaX: + +1. initializes its own non-virtual state; +2. calls ``_before_setup(console)``; +3. applies the color mode through ``_update_color_mode(startup=True)``; +4. creates the generic status bar, actions, central widget, menus, and restored + window state; +5. calls ``_after_setup(console)``; and +6. restores the window geometry. + +Use ``_before_setup`` for derived state that another protected hook may need +during startup. Use ``_after_setup`` for work that requires generic widgets +such as actions, menus, or the status bar. + +The HDF5 reference application follows this rule by creating its data model in +``_before_setup``: + +.. literalinclude:: ../../sigimax/tests/hdf5/test_h5_derived_app.py + :language: python + :pyobject: DerivedAppWindow._before_setup + +Singleton Access +---------------- + +Call ``MyMainWindow.get_instance()`` on the concrete derived class. It returns +the current instance of that class or creates one of the same class. This keeps +the base framework from accidentally constructing ``SGMXMainWindow`` when an +application requested its own window subclass. + +Shutdown Order +-------------- + +For a modified workspace, the close flow asks the user to save first. A +successful application save clears the modified state; cancellation or failure +leaves it set and stops the close. Once closing proceeds, SigimaX calls +``_close_managed_widgets()``, ``_cleanup_before_reset()``, +``reset_all()``, ``_save_pos_size_and_state()``, and +``_cleanup_after_state_save()`` in that order. + +Derived applications should use these hooks for their own managed resources and +call ``super()`` when preserving the generic behavior is required. \ No newline at end of file diff --git a/doc/user_guide/overview.rst b/doc/user_guide/overview.rst new file mode 100644 index 0000000..6f2537d --- /dev/null +++ b/doc/user_guide/overview.rst @@ -0,0 +1,188 @@ +.. _overview: + +Overview +======== + +What is SigimaX? +----------------- + +**SigimaX** is an open-source Python framework for building Qt-based scientific +desktop applications. It provides a reusable application skeleton — main window, +configuration system, embedded widgets, and HDF5 infrastructure — so that +developers can focus on domain-specific features instead of boilerplate. + +SigimaX is extracted from `DataLab `_, a mature +open-source platform for scientific signal and image processing, and powers its +GUI layer. + +Why SigimaX? +------------- + +Building a scientific desktop application from scratch typically requires: + +- A main window with menus, toolbars, dock widgets, and status bar +- A configuration system with typed options, persistence, and defaults +- HDF5 file management (industry standard for scientific data) +- An embedded Python console for scripting and debugging +- Specialized dialogs for signal/image analysis (fitting, peak detection, etc.) +- Production-grade features: splash screen, memory monitoring, log viewer + +SigimaX provides all of these as a **subclassable framework**, so derived +applications only need to add their domain-specific logic. + +Position in the Stack +--------------------- + +SigimaX sits between the low-level libraries (PlotPy, guidata, PythonQwt) and +end-user applications: + +.. code-block:: text + + End-user apps (DataLab, custom scientific apps) + ↓ subclass / configure + SigimaX ← THIS PROJECT (framework layer) + ↓ depends on + Sigima (computation) + PlotPy + guidata + PythonQwt + ↓ + NumPy / SciPy / Qt + +.. list-table:: + :header-rows: 1 + :align: left + + * - Layer + - Project + - Role + * - End-user apps + - DataLab + - Signal/image processing GUI application + * - GUI framework + - **SigimaX** + - Reusable application skeleton + * - Computation + - Sigima + - Headless scientific computing (signals & images) + * - Plotting + - PlotPy + PythonQwt + - Interactive plot widgets + * - GUI toolkit + - guidata + - Dataset/parameter framework with automatic GUI generation + * - Foundation + - NumPy + SciPy + Qt + - Core scientific and GUI libraries + +Architecture +------------ + +.. code-block:: text + + sigimax/ + ├── app.py # Application launcher (create / run) + ├── config.py # Configuration system (SigimaXOptions, CONF singleton) + ├── env.py # Runtime environment (verbosity, unattended mode) + ├── mainwindow.py # SGMXMainWindow (generic main window) + ├── widgets/ # Reusable Qt widgets + │ ├── plotdock.py # DockablePlotWidget + │ ├── splashscreen.py # Configurable splash screen + │ ├── h5browser.py # HDF5 file browser + │ ├── logviewer.py # Log viewer dialog + │ ├── status.py # Status bar widgets (memory, console) + │ ├── fitdialog.py # Curve fitting dialogs + │ ├── signalpeak.py # Signal peak detection + │ ├── signalbaseline.py # Signal baseline selection + │ ├── signalcursor.py # Signal cursor selection + │ ├── signaldeltax.py # Signal delta-X measurement + │ ├── wizard.py # Multi-page wizard dialog + │ └── ... # File dialogs, warning/error boxes + ├── h5/ # HDF5 I/O (read/write/import) + ├── adapters_plotpy/ # Converters between PlotPy/guidata and Sigima objects + ├── utils/ # Qt helpers, config dir resolution + ├── data/ # Icons, resources + └── locale/ # Translations (EN, FR) + +Core Modules +^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :align: left + + * - Module + - Purpose + * - :mod:`sigimax.app` + - Application launcher — ``create()`` and ``run()`` functions + * - :mod:`sigimax.config` + - Configuration system — ``SigimaXOptions``, ``CONF`` singleton, option fields + * - :mod:`sigimax.env` + - Runtime environment — ``SGMXExecEnv``, verbosity levels, unattended mode + * - :mod:`sigimax.mainwindow` + - ``SGMXMainWindow`` — generic main window with menus, console, HDF5 workspace + +Design Philosophy +----------------- + +SigimaX separates the **generic application skeleton** from **domain-specific logic**. +Derived applications follow a three-step pattern: + +1. **Subclass** :class:`~sigimax.config.SigimaXOptions` to add application-specific + configuration fields +2. **Subclass** :class:`~sigimax.mainwindow.SGMXMainWindow` to customize menus, + toolbars, and dock widgets +3. **Call** :func:`~sigimax.app.run` to launch the application with splash screen + support + +This architecture is proven in production: +`DataLab `_ is built entirely on this pattern. + +Configuration System +^^^^^^^^^^^^^^^^^^^^ + +The configuration system uses typed option fields that support: + +- **Type safety**: ``TypedOptionField`` (int, str, bool, float), + ``EnumOptionField`` (constrained choices), ``TupleOptionField`` (fixed-length tuples), + ``FontOptionField`` (font validation) +- **Persistence**: JSON save/load with ``save()`` and ``load()`` methods +- **Context managers**: Temporary overrides with ``option.context(value)`` +- **Defaults**: Built-in reset-to-defaults mechanism + +.. code-block:: python + + from sigimax.config import CONF as Conf + + # Get/set options + colormap = Conf.ima_def_colormap.get() + Conf.ima_def_colormap.set("gray") + + # Context manager for temporary overrides + with Conf.fft_shift_enabled.context(False): + # FFT shift disabled in this block + ... + +What SigimaX Provides vs What Stays in Derived Apps +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :align: left + :widths: 50 50 + + * - **In SigimaX** + - **Stays in derived apps (e.g. DataLab)** + * - Configuration system + - Signal/Image panels, processors + * - Generic main window + - Action handler, plugin system + * - Dockable plot widgets + - Remote control (XML-RPC, Web API) + * - HDF5 I/O + browser + - Macro editor, new-object dialogs + * - Scientific dialogs (fit, baseline, peak, cursor…) + - Application-specific UI and processing + * - Log viewer, status bar, splash screen, wizard + - Object model, plot handler + * - PlotPy adapters + - Processor registration pattern + * - Environment/exec utilities + - Tour/tutorial features diff --git a/pyproject.toml b/pyproject.toml index 1c08993..53b94b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ name = "sigimax" authors = [{ name = "Pierre Raybaut", email = "p.raybaut@codra.fr" }] maintainers = [ - { name = "DataLab Platform Developers", email = "p.raybaut@codra.fr" }, + { name = "DataLab Platform Developers", email = "datalab@codra.fr" }, ] description = "Reusable GUI components and application utilities, part of the DataLab platform for signal and image processing" readme = "README.md" @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Image Processing", "Topic :: Scientific/Engineering :: Human Machine Interfaces", @@ -36,8 +37,10 @@ classifiers = [ ] requires-python = ">=3.9, <4" dependencies = [ - "PlotPy >= 2.7.4", + "guidata >= 3.13.4", + "PlotPy >= 2.8.2", "psutil >= 5.7", + "Sigima >= 1.1.0", ] dynamic = ["version"] @@ -50,14 +53,16 @@ Homepage = "https://datalab-platform.com/" Documentation = "https://datalab-platform.com/" [project.optional-dependencies] -opencv = ["opencv-python-headless >= 4.5"] +qt = ["PyQt5 >= 5.15.6"] dev = ["ruff", "pylint", "Coverage"] doc = [ "sphinx", "sphinx_intl", "sphinx-sitemap", "myst_parser", + "myst-nb", "sphinx_design", + "sphinx_gallery", "sphinx-copybutton", "pydata-sphinx-theme", ] @@ -91,10 +96,21 @@ include = ["sigimax*"] ] [tool.setuptools.dynamic] -version = { attr = "sigimax.__version__" } +version = { attr = "sigimax._metadata.__version__" } [tool.pytest.ini_options] -addopts = "sigimax" +addopts = "sigimax --import-mode=importlib" +filterwarnings = [ + "ignore::DeprecationWarning:pandas.*", + "ignore::DeprecationWarning:scipy.*", + "ignore::DeprecationWarning:skimage.*", +] +markers = [ + "validation: mark a test as a validation test (ground truth or analytical)", + "unit: pure logic test, no Qt application context needed", + "app: requires full application context (SGMXMainWindow)", + "gui: requires visible Qt window (use --show-windows)", +] [tool.ruff] exclude = [".git", ".vscode", "build", "dist", "*.ipynb"] @@ -104,7 +120,7 @@ target-version = "py39" # Assume Python 3.9. [tool.ruff.lint] # all rules can be found here: https://beta.ruff.rs/docs/rules/ -select = ["E", "F", "W", "I", "NPY201"] +select = ["D202", "D403", "E", "F", "I", "NPY201", "RUF022", "W"] ignore = [ "E203", # space before : (needed for how black formats slicing) ] @@ -115,5 +131,8 @@ indent-style = "space" # Like Black, indent with spaces, rather than skip-magic-trailing-comma = false # Like Black, respect magic trailing commas. line-ending = "auto" # Like Black, automatically detect the appropriate line ending. +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.ruff.lint.per-file-ignores] "doc/*" = ["E402"] diff --git a/requirements.txt b/requirements.txt index 2199d6f..f507691 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,19 @@ -PyQt5 -plotpy -PythonQwt -psutil +Coverage +PlotPy >= 2.8.2 +PyQt5 >= 5.15.6 +Sigima >= 1.1.0 +guidata >= 3.13.4 +myst-nb +myst_parser +psutil >= 5.7 +pydata-sphinx-theme pylint -ruff -coverage pytest pytest-xvfb -pycodestyle -pyinstaller>=6.2 +ruff sphinx -sphinx-intl +sphinx-copybutton sphinx-sitemap -myst_parser sphinx_design -sphinx-copybutton -pydata-sphinx-theme -esbonio -rstcheck -doc8 -build -twine \ No newline at end of file +sphinx_gallery +sphinx_intl diff --git a/scripts/reinstall_dev.py b/scripts/reinstall_dev.py new file mode 100644 index 0000000..aff3642 --- /dev/null +++ b/scripts/reinstall_dev.py @@ -0,0 +1,111 @@ +# script/reinstall_dev.py +""" +Reinstall multiple local libraries in editable mode for development. + +Workflow: + 1) Try to uninstall all target libraries in one command (ignore errors if some are not + installed). + 2) Reinstall each library in editable mode from a sibling folder: ../. + +This script uses the same Python interpreter that runs it (sys.executable), +so pip operations happen in the same environment (e.g., your active venv). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import sysconfig +from typing import Iterable + +# Absolute path to the Python executable that runs this script. +# Using sys.executable ensures pip targets the same environment (e.g., your venv). +PY = sys.executable + + +def run(cmd: list[str]) -> None: + """ + Echo and execute a subprocess command. + Raises: + subprocess.CalledProcessError: if the command returns a non-zero exit code. + """ + print("$", " ".join(cmd), flush=True) + subprocess.check_call(cmd) + + +def uninstall_many(packages: Iterable[str]) -> None: + """ + Attempt to uninstall multiple packages at once. + If uninstall fails (e.g., a package is not installed), we keep going. + + Args: + packages: An iterable of package names (pip distribution names). + """ + pkgs = list(packages) + if not pkgs: + return + try: + run([PY, "-m", "pip", "uninstall", "-y", *pkgs]) + except subprocess.CalledProcessError as e: + # Continue even if the uninstall step fails (for one or more packages) + print(f"[WARN] Uninstall returned {e.returncode} — continuing...", flush=True) + + +def install_editable_many(packages: Iterable[str]) -> None: + """ + Install each package in editable mode from ../. + + Assumes your project layout has sibling folders one level up, e.g.: + ../guidata + + Args: + packages: An iterable of package names (also used as directory names). + """ + for pkg in packages: + run([PY, "-m", "pip", "install", "-e", f"../{pkg}"]) + + +def remove_residual_dirs(packages: Iterable[str]) -> None: + """ + Force remove residual package directories from site-packages. + This mimics: rm -rf .venv/Lib/site-packages/ + to ensure a clean slate before reinstalling. + """ + # Locates site-packages, e.g. .venv/Lib/site-packages + site_packages = sysconfig.get_path("purelib") + + for pkg in packages: + target = os.path.join(site_packages, pkg) + if os.path.isdir(target): + print(f"Removing residual directory: {target}", flush=True) + try: + shutil.rmtree(target) + except OSError as e: + print(f"[WARN] Failed to remove {target}: {e}", flush=True) + + +def reinstall_packages(packages: list[str]) -> None: + """ + High-level orchestration for many packages: + - Uninstall all of them (ignore failures) + - Force remove residual directories from site-packages + - Install each in editable mode + """ + # 1) Uninstall (ignore if not installed) + uninstall_many(packages) + + # 2) Force remove residual directories (essential for clean reinstall) + remove_residual_dirs(packages) + + # 3) Editable installs + install_editable_many(packages) + + +if __name__ == "__main__": + # ⭐ Fixed, editable local libraries to manage (edit as needed) + PACKAGES = ["guidata", "plotpy", "sigima"] + + print("🏃 Reinstalling editable packages:", ", ".join(PACKAGES)) + reinstall_packages(PACKAGES) diff --git a/scripts/run_with_env.py b/scripts/run_with_env.py new file mode 100644 index 0000000..6688cb6 --- /dev/null +++ b/scripts/run_with_env.py @@ -0,0 +1,210 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Run a command with environment variables loaded from a .env file. + +This script automatically detects the best Python interpreter to use: + +1. ``PYTHON`` variable in ``.env`` file (e.g. for WinPython distributions) +2. ``WINPYDIRBASE`` variable (legacy WinPython base directory) +3. ``VENV_DIR`` variable (explicit virtual environment directory) +4. A local virtual environment (``.venv*`` directory in the project root) +5. Falls back to ``sys.executable`` (the Python that launched this script) + +This ensures that VS Code tasks always use the correct Python environment +regardless of which interpreter is configured globally or in VS Code. +""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + + +def _find_venv_python(project_root: Path) -> str | None: + """Find a Python executable in a ``.venv*`` directory. + + Searches for directories matching ``.venv*`` in the project root and + returns the first valid Python executable found. + + Args: + project_root: The root directory of the project. + + Returns: + Absolute path to the venv Python executable, or None if not found. + """ + # Sort to prefer ".venv" over ".venv-xyz" etc. + venv_dirs = sorted(glob.glob(str(project_root / ".venv*"))) + for venv_dir in venv_dirs: + venv_path = Path(venv_dir) + if not venv_path.is_dir(): + continue + result = _get_venv_python(venv_path) + if result: + return result + return None + + +def _get_venv_python(venv_dir: Path) -> str | None: + """Get the Python executable from a specific venv directory. + + Args: + venv_dir: Path to the virtual environment directory. + + Returns: + Absolute path to the Python executable, or None if not found. + """ + if not venv_dir.is_dir(): + return None + # Windows: Scripts/python.exe — Unix: bin/python + candidates = [ + venv_dir / "Scripts" / "python.exe", + venv_dir / "bin" / "python", + ] + for candidate in candidates: + if candidate.is_file(): + # Keep the venv-local executable path without resolving symlinks: + # on Linux/WSL, ``bin/python`` is often a symlink to a global + # interpreter (e.g. /usr/bin/python3.x). Resolving it would lose + # venv context and site-packages selection. + return str(candidate.absolute()) + return None + + +def resolve_python(project_root: Path) -> str: + """Resolve the best Python interpreter for the project. + + Priority order: + + 1. ``PYTHON`` environment variable (set in ``.env`` or externally) + 2. ``WINPYDIRBASE`` environment variable (legacy WinPython base directory) + 3. ``VENV_DIR`` environment variable (explicit venv directory) + 4. ``.venv*`` directory in *project_root* (auto-discovery) + 5. ``sys.executable`` (the interpreter running this script) + + Args: + project_root: The root directory of the project. + + Returns: + Absolute path to the Python executable to use. + """ + # 1. Explicit PYTHON variable (e.g. WinPython distribution) + python_env = os.environ.get("PYTHON") + if python_env: + python_path = Path(python_env) + if python_path.is_file(): + # Do not resolve symlinks for the same reason as in + # ``_get_venv_python``. + resolved = str(python_path.absolute()) + print(f" 🐍 Using PYTHON from .env: {resolved}") + return resolved + print(f" ⚠️ PYTHON variable set but not found: {python_env}") + + # 2. Legacy WINPYDIRBASE variable (WinPython distribution) + winpy_base = os.environ.get("WINPYDIRBASE") + if winpy_base and Path(winpy_base).is_dir(): + # Search for python.exe in the WinPython directory structure + # Patterns: python-3.11.5.amd64/python.exe (old) or python/python.exe (new) + for pattern in ("python-*/python.exe", "python/python.exe"): + for candidate in sorted(Path(winpy_base).glob(pattern)): + if candidate.is_file(): + resolved = str(candidate.absolute()) + print(f" 🐍 Using WINPYDIRBASE (legacy): {resolved}") + return resolved + # Also try direct python.exe in the base directory + direct = Path(winpy_base) / "python.exe" + if direct.is_file(): + resolved = str(direct.absolute()) + print(f" 🐍 Using WINPYDIRBASE (legacy): {resolved}") + return resolved + print(f" ⚠️ WINPYDIRBASE set but no Python found in: {winpy_base}") + + # 3. Explicit VENV_DIR variable (e.g. for multiple local venvs) + venv_dir_env = os.environ.get("VENV_DIR") + if venv_dir_env: + venv_dir = Path(venv_dir_env) + if not venv_dir.is_absolute(): + venv_dir = project_root / venv_dir + venv_python = _get_venv_python(venv_dir) + if venv_python: + print(f" 🐍 Using VENV_DIR from .env: {venv_python}") + return venv_python + print(f" ⚠️ VENV_DIR set but no Python found in: {venv_dir}") + + # 4. Auto-discover local venv + venv_python = _find_venv_python(project_root) + if venv_python: + print(f" 🐍 Using venv Python: {venv_python}") + return venv_python + + # 5. Fallback + print(f" 🐍 Using caller Python: {sys.executable}") + return sys.executable + + +def load_env_file(env_path: str | None = None) -> None: + """Load environment variables from a .env file.""" + if env_path is None: + env_path = Path.cwd() / ".env" + if not Path(env_path).is_file(): + raise FileNotFoundError(f"Environment file not found: {env_path}") + print(f"Loading environment variables from: {env_path}") + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip().strip('"').strip("'") + os.environ[key.strip()] = value + print(f" Loaded variable: {key.strip()}={value}") + + +def execute_command(command: list[str], python_exe: str) -> int: + """Execute a command, replacing ``python`` placeholders. + + Any argument that is the bare word ``python`` or that points to a Python + executable (checked via filename) is replaced by *python_exe* so that the + subprocess uses the resolved interpreter rather than the global one. + + Args: + command: The command and its arguments. + python_exe: The resolved Python interpreter path. + + Returns: + The subprocess exit code. + """ + resolved: list[str] = [] + for arg in command: + if arg.lower() == "python" or ( + Path(arg).name.lower().startswith("python") + and Path(arg).is_file() + and arg.lower() != python_exe.lower() + ): + resolved.append(python_exe) + else: + resolved.append(arg) + print("Executing command:") + print(" ".join(resolved)) + print("") + result = subprocess.call(resolved) + print(f"Process exited with code {result}") + return result + + +def main() -> None: + """Main function to load environment variables and execute a command.""" + if len(sys.argv) < 2: + print("Usage: python run_with_env.py [args ...]") + sys.exit(1) + print("🏃 Running with environment variables") + project_root = Path.cwd() + load_env_file() + python_exe = resolve_python(project_root) + return execute_command(sys.argv[1:], python_exe) + + +if __name__ == "__main__": + main() diff --git a/sigimax/__init__.py b/sigimax/__init__.py index adaf2f3..c504c3c 100644 --- a/sigimax/__init__.py +++ b/sigimax/__init__.py @@ -1,2 +1,36 @@ -"""Placeholder for future SigimaX package.""" -__version__ = "0.0.1.dev0" +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX +======= + +SigimaX is a generic app components library based on Python +scientific libraries (such as NumPy, SciPy or scikit-image) and Qt graphical +user interfaces (thanks to `PlotPyStack`_ libraries). + +It helps building scientific computing applications by providing a set of GUI modules. + +.. _PlotPyStack: https://github.com/PlotPyStack +""" + +__all__ = [ + "SGMXMainWindow", + "create", + "run", +] + +from sigimax._metadata import ( # noqa: F401 + __docurl__, + __homeurl__, + __supporturl__, + __version__, +) +from sigimax.app import create, run +from sigimax.mainwindow import SGMXMainWindow + +# --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools +# --- to retrieve data and translation files paths +# +# Dear (Debian, RPM, ...) package makers, please feel free to customize the +# following path to module's data (images) and translations: +DATAPATH = LOCALEPATH = "" diff --git a/sigimax/_metadata.py b/sigimax/_metadata.py new file mode 100644 index 0000000..7c05063 --- /dev/null +++ b/sigimax/_metadata.py @@ -0,0 +1,30 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX package metadata +========================= + +This module centralizes package-level metadata (version, URLs) in a single +file that has **no internal imports**. + +Why a separate module? +~~~~~~~~~~~~~~~~~~~~~~ + +SigimaX is a framework library whose ``__init__.py`` re-exports key symbols +(``SGMXMainWindow``, ``create``, ``run``) for convenience. Those re-exports +pull in heavy submodules (``sigimax.app``, ``sigimax.mainwindow``) which +eventually import ``sigimax.config``. If ``sigimax.config`` were to import +metadata back from ``sigimax.__init__``, a circular import chain would form:: + + sigimax → sigimax.mainwindow → sigimax.widgets.* → sigimax.config → sigimax + +By placing the metadata here, both ``__init__.py`` and ``config.py`` can +import it without triggering the cycle. This is the same pattern used by +projects like Flask and setuptools-scm that need a rich ``__init__.py`` +alongside internal access to version information. +""" + +__version__ = "0.1.0" +__docurl__ = "https://sigimax.readthedocs.io/" +__homeurl__ = "https://github.com/DataLab-Platform/SigimaX" +__supporturl__ = "https://github.com/DataLab-Platform/SigimaX/issues/new/choose" diff --git a/sigimax/adapters_plotpy/__init__.py b/sigimax/adapters_plotpy/__init__.py new file mode 100644 index 0000000..84447ea --- /dev/null +++ b/sigimax/adapters_plotpy/__init__.py @@ -0,0 +1,91 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. +""" +Adapters for PlotPy +=================== + +The :mod:`sigimax.adapters_plotpy` package provides adapters for +PlotPy to integrate with SigimaX's data model and GUI. + +Each Sigima object (:class:`~sigima.objects.SignalObj`, +:class:`~sigima.objects.ImageObj`, ROI classes) is converted to/from PlotPy +plot items by a dedicated adapter class. :class:`PlotPyAdapterFactory` +resolves which adapter class to use for a given object; derived applications +subclass the factory to override or extend this resolution and install it +with :func:`set_adapter_factory`. + +.. autoclass:: PlotPyAdapterFactory + :members: +.. autofunction:: get_adapter_factory +.. autofunction:: set_adapter_factory +.. autofunction:: reset_adapter_factory +.. autofunction:: create_adapter_from_object +.. autoclass:: SignalObjPlotPyAdapter + :members: +.. autoclass:: ImageObjPlotPyAdapter + :members: +.. autoclass:: SegmentROIPlotPyAdapter + :members: +.. autoclass:: SignalROIPlotPyAdapter + :members: +.. autoclass:: RectangularROIPlotPyAdapter + :members: +.. autoclass:: CircularROIPlotPyAdapter + :members: +.. autoclass:: PolygonalROIPlotPyAdapter + :members: +.. autofunction:: items_to_json +.. autofunction:: json_to_items +.. autofunction:: plotitem_to_singleroi +.. autofunction:: singleroi_to_plotitem +.. autofunction:: configure_roi_item +""" + +from __future__ import annotations + +from .base import items_to_json, json_to_items +from .converters import ( + create_adapter_from_object, + plotitem_to_singleroi, + singleroi_to_plotitem, +) +from .factories import ( + PlotPyAdapterFactory, + get_adapter_factory, + reset_adapter_factory, + set_adapter_factory, +) +from .objects.base import TypePlotItem +from .objects.image import ( + ImageObjPlotPyAdapter, +) +from .objects.signal import CURVESTYLES, SignalObjPlotPyAdapter +from .roi.base import TypeROIItem, configure_roi_item +from .roi.image import ( + CircularROIPlotPyAdapter, + PolygonalROIPlotPyAdapter, + RectangularROIPlotPyAdapter, +) +from .roi.signal import SegmentROIPlotPyAdapter, SignalROIPlotPyAdapter + +__all__ = [ + "CURVESTYLES", + "CircularROIPlotPyAdapter", + "ImageObjPlotPyAdapter", + "PlotPyAdapterFactory", + "PolygonalROIPlotPyAdapter", + "RectangularROIPlotPyAdapter", + "SegmentROIPlotPyAdapter", + "SignalObjPlotPyAdapter", + "SignalROIPlotPyAdapter", + "TypePlotItem", + "TypeROIItem", + "configure_roi_item", + "create_adapter_from_object", + "get_adapter_factory", + "items_to_json", + "json_to_items", + "plotitem_to_singleroi", + "reset_adapter_factory", + "set_adapter_factory", + "singleroi_to_plotitem", +] diff --git a/sigimax/adapters_plotpy/annotations.py b/sigimax/adapters_plotpy/annotations.py new file mode 100644 index 0000000..c9eab92 --- /dev/null +++ b/sigimax/adapters_plotpy/annotations.py @@ -0,0 +1,124 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Annotation Adapter for PlotPy Integration +----------------------------------------- + +This module bridges Sigima's format-agnostic annotation storage with PlotPy's +plot item system. It handles bidirectional conversion between: +- Sigima: list[dict] (JSON-serializable) +- PlotPy: list[AnnotatedShape] (plot items) +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from guidata.io import JSONReader, JSONWriter +from plotpy.io import load_items, save_items + +if TYPE_CHECKING: + from plotpy.items import AnnotatedShape + from sigima.objects.base import BaseObj + + +class PlotPyAnnotationAdapter: + """Adapter for converting between Sigima annotations and PlotPy items. + + This class provides the bridge between Sigima's generic annotation storage + (list of dicts) and PlotPy's specific plot item format. + + Example: + >>> from sigima.objects.signal.creation import create_signal + >>> obj = create_signal("Test") + >>> adapter = PlotPyAnnotationAdapter(obj) + >>> + >>> # Add PlotPy items + >>> from plotpy.items import AnnotatedRectangle + >>> rect = AnnotatedRectangle(0, 0, 10, 10) + >>> adapter.add_items([rect]) + >>> + >>> # Retrieve as PlotPy items + >>> items = adapter.get_items() + >>> len(items) + 1 + """ + + def __init__(self, obj: BaseObj): + """Initialize adapter with an object. + + Args: + obj: Signal or image object with annotation support + """ + self.obj = obj + + def get_items(self) -> list[AnnotatedShape]: + """Get annotations as PlotPy items. + + Returns: + List of PlotPy annotation items + + Notes: + This method deserializes the JSON data stored in the object using + PlotPy's load_items() function. + """ + annotations = self.obj.get_annotations() + if not annotations: + return [] + + items = [] + for ann_dict in annotations: + # Each annotation dict should contain PlotPy's JSON serialization + if "plotpy_json" in ann_dict: + try: + json_str = ann_dict["plotpy_json"] + for item in load_items(JSONReader(json_str)): + items.append(item) + except (json.JSONDecodeError, ValueError, KeyError): + # Skip invalid items + continue + + return items + + def set_items(self, items: list[AnnotatedShape]) -> None: + """Set annotations from PlotPy items. + + Args: + items: List of PlotPy annotation items + + Notes: + This method serializes PlotPy items to JSON using PlotPy's + save_items() function and stores them in the Sigima format. + """ + if not items: + self.obj.clear_annotations() + return + + # Convert PlotPy items to our annotation format + annotations = [] + for item in items: + writer = JSONWriter(None) + save_items(writer, [item]) + ann_dict = { + "type": "plotpy_item", + "item_class": item.__class__.__name__, + "plotpy_json": writer.get_json(), + } + annotations.append(ann_dict) + + self.obj.set_annotations(annotations) + + def add_items(self, items: list[AnnotatedShape]) -> None: + """Add PlotPy items to existing annotations. + + Args: + items: List of PlotPy annotation items to add + """ + current_items = self.get_items() + current_items.extend(items) + self.set_items(current_items) + + def clear(self) -> None: + """Clear all annotations.""" + self.obj.clear_annotations() diff --git a/sigimax/adapters_plotpy/base.py b/sigimax/adapters_plotpy/base.py new file mode 100644 index 0000000..6cefaf5 --- /dev/null +++ b/sigimax/adapters_plotpy/base.py @@ -0,0 +1,110 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Base Module +-------------------------- +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from guidata.io import JSONReader, JSONWriter +from plotpy.io import load_items, save_items +from plotpy.items import ( + AbstractLabelItem, + AnnotatedSegment, + AnnotatedShape, +) + +if TYPE_CHECKING: + from plotpy.items import AbstractShape + from plotpy.styles import AnnotationParam + + +def config_annotated_shape( + item: AnnotatedShape, + fmt: str, + lbl: bool, + section: str | None = None, + option: str | None = None, + show_computations: bool | None = None, +): + """Configurate annotated shape + + Args: + item: Annotated shape item + fmt: Format string + lbl: Show label + section: Shape style section (e.g. "plot") + option: Shape style option (e.g. "shape/drag") + show_computations: Show computations + """ + param: AnnotationParam = item.annotationparam + param.format = fmt + param.show_label = lbl + if show_computations is not None: + param.show_computations = show_computations + + if isinstance(item, AnnotatedSegment): + item.label.labelparam.anchor = "T" + item.label.labelparam.update_item(item.label) + + param.update_item(item) + if section is not None and option is not None: + item.set_style(section, option) + + +# TODO: [P3] Move this function as a method of plot items in PlotPy +def set_plot_item_editable( + item: AbstractShape | AbstractLabelItem | AnnotatedShape, state +): + """Set plot item editable state + + Args: + item: Plot item + state: State + """ + item.set_movable(state) + item.set_resizable(state and not isinstance(item, AbstractLabelItem)) + item.set_rotatable(state and not isinstance(item, AbstractLabelItem)) + item.set_readonly(not state) + item.set_selectable(state) + + +def items_to_json(items: list) -> str | None: + """Convert plot items to JSON string + + Args: + items: list of plot items + + Returns: + JSON string or None if items is empty + """ + if items: + writer = JSONWriter(None) + save_items(writer, items) + return writer.get_json(indent=4) + return None + + +def json_to_items(json_str: str | None) -> list: + """Convert JSON string to plot items + + Args: + json_str: JSON string or None + + Returns: + List of plot items + """ + items = [] + if json_str: + try: + for item in load_items(JSONReader(json_str)): + items.append(item) + except json.decoder.JSONDecodeError: + pass + return items diff --git a/sigimax/adapters_plotpy/converters.py b/sigimax/adapters_plotpy/converters.py new file mode 100644 index 0000000..48d62ad --- /dev/null +++ b/sigimax/adapters_plotpy/converters.py @@ -0,0 +1,70 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Converters +------------------------- +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from plotpy.items import ( + AnnotatedCircle, + AnnotatedPolygon, + AnnotatedRectangle, + AnnotatedXRange, +) +from sigima.objects import ( + CircularROI, + PolygonalROI, + RectangularROI, + SegmentROI, +) + +from sigimax.adapters_plotpy.factories import ( + create_adapter_from_object, + get_adapter_factory, +) + +if TYPE_CHECKING: + from sigima.objects import ImageObj, SignalObj + + +def plotitem_to_singleroi( + plot_item: AnnotatedXRange + | AnnotatedRectangle + | AnnotatedCircle + | AnnotatedPolygon, + obj: SignalObj | ImageObj | None = None, +) -> SegmentROI | RectangularROI | CircularROI | PolygonalROI: + """Create a single ROI from the given PlotPy item to integrate with SigimaX + + Args: + plot_item: The PlotPy item for which to create a single ROI + obj: Optional signal or image object for coordinate rounding + + Returns: + A single ROI instance + """ + adapter = get_adapter_factory().get_adapter_class_for_plot_item(plot_item) + return adapter.from_plot_item(plot_item, obj) + + +def singleroi_to_plotitem( + roi: SegmentROI | RectangularROI | CircularROI | PolygonalROI, + obj: SignalObj | ImageObj, +) -> AnnotatedXRange | AnnotatedRectangle | AnnotatedCircle | AnnotatedPolygon: + """Create a PlotPy item from the given single ROI to integrate with SigimaX + + Args: + roi: The single ROI for which to create a PlotPy item + obj: The object (signal or image) associated with the ROI + + Returns: + A PlotPy item instance + """ + adapter = create_adapter_from_object(roi) + return adapter.to_plot_item(obj) diff --git a/sigimax/adapters_plotpy/coordutils.py b/sigimax/adapters_plotpy/coordutils.py new file mode 100644 index 0000000..f63b634 --- /dev/null +++ b/sigimax/adapters_plotpy/coordutils.py @@ -0,0 +1,157 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +ROI Coordinate Utilities +========================= + +This module provides utility functions for rounding ROI coordinates to appropriate +precision based on the sampling characteristics of signals and images. + +These functions are used when converting interactive PlotPy shapes to ROI objects +to ensure coordinates are displayed with reasonable precision. +""" + +from __future__ import annotations + +import numpy as np +from sigima.objects import ImageObj, ROI1DParam, ROI2DParam, SignalObj + + +def round_signal_coords( + obj: SignalObj, coords: list[float], precision_factor: float = 0.1 +) -> list[float]: + """Round signal coordinates to appropriate precision based on sampling period. + + Rounds to a fraction of the median sampling period to avoid excessive decimal + places while maintaining reasonable precision. + + Args: + obj: signal object + coords: coordinates to round + precision_factor: fraction of sampling period to use as rounding precision. + Default is 0.1 (1/10th of sampling period). + + Returns: + Rounded coordinates + """ + if len(obj.x) < 2: + # Cannot compute sampling period, return coords as-is + return coords + # Compute median sampling period + sampling_period = float(np.median(np.diff(obj.x))) + if sampling_period == 0: + # Avoid division by zero for constant x arrays + return coords + # Round to specified fraction of sampling period + precision = sampling_period * precision_factor + # Determine number of decimal places + if precision > 0: + decimals = max(0, int(-np.floor(np.log10(precision)))) + return [round(c, decimals) for c in coords] + return coords + + +def round_image_coords( + obj: ImageObj, coords: list[float], precision_factor: float = 0.1 +) -> list[float]: + """Round image coordinates to appropriate precision based on pixel spacing. + + Rounds to a fraction of the pixel spacing to avoid excessive decimal places + while maintaining reasonable precision. Uses separate precision for X and Y. + + Args: + obj: image object + coords: flat list of coordinates [x0, y0, x1, y1, ...] to round + precision_factor: fraction of pixel spacing to use as rounding precision. + Default is 0.1 (1/10th of pixel spacing). + + Returns: + Rounded coordinates + + Raises: + ValueError: if coords does not contain an even number of elements + """ + if len(coords) % 2 != 0: + raise ValueError("coords must contain an even number of elements (x, y pairs).") + if len(coords) == 0: + return coords + + rounded = list(coords) + if obj.is_uniform_coords: + # Use dx, dy for uniform coordinates + precision_x = abs(obj.dx) * precision_factor + precision_y = abs(obj.dy) * precision_factor + else: + # Compute average spacing for non-uniform coordinates + if len(obj.xcoords) > 1: + avg_dx = float(np.mean(np.abs(np.diff(obj.xcoords)))) + precision_x = avg_dx * precision_factor + else: + precision_x = 0 + if len(obj.ycoords) > 1: + avg_dy = float(np.mean(np.abs(np.diff(obj.ycoords)))) + precision_y = avg_dy * precision_factor + else: + precision_y = 0 + + # Round X coordinates (even indices) + if precision_x > 0: + decimals_x = max(0, int(-np.floor(np.log10(precision_x)))) + for i in range(0, len(rounded), 2): + rounded[i] = round(rounded[i], decimals_x) + + # Round Y coordinates (odd indices) + if precision_y > 0: + decimals_y = max(0, int(-np.floor(np.log10(precision_y)))) + for i in range(1, len(rounded), 2): + rounded[i] = round(rounded[i], decimals_y) + + return rounded + + +def round_signal_roi_param( + obj: SignalObj, param: ROI1DParam, precision_factor: float = 0.1 +) -> None: + """Round signal ROI parameter coordinates in-place. + + Args: + obj: signal object + param: ROI parameter to round (modified in-place) + precision_factor: fraction of sampling period to use as rounding precision + """ + coords = round_signal_coords(obj, [param.xmin, param.xmax], precision_factor) + param.xmin, param.xmax = coords + + +def round_image_roi_param( + obj: ImageObj, param: ROI2DParam, precision_factor: float = 0.1 +) -> None: + """Round image ROI parameter coordinates in-place. + + Args: + obj: image object + param: ROI parameter to round (modified in-place) + precision_factor: fraction of pixel spacing to use as rounding precision + """ + if param.geometry == "rectangle": + # Round x0, y0, dx, dy + x0, y0, x1, y1 = param.x0, param.y0, param.x0 + param.dx, param.y0 + param.dy + coords = round_image_coords(obj, [x0, y0, x1, y1], precision_factor) + param.x0, param.y0 = coords[0], coords[1] + # Round dx and dy to avoid floating-point errors in subtraction + dx_dy_rounded = round_image_coords( + obj, [coords[2] - coords[0], coords[3] - coords[1]], precision_factor + ) + param.dx = dx_dy_rounded[0] + param.dy = dx_dy_rounded[1] + elif param.geometry == "circle": + # Round xc, yc, r + coords = round_image_coords(obj, [param.xc, param.yc], precision_factor) + param.xc, param.yc = coords + # Round radius using X precision + r_rounded = round_image_coords(obj, [param.r, 0], precision_factor)[0] + param.r = r_rounded + elif param.geometry == "polygon": + # Round polygon points + rounded = round_image_coords(obj, param.points.tolist(), precision_factor) + param.points = np.array(rounded) diff --git a/sigimax/adapters_plotpy/factories.py b/sigimax/adapters_plotpy/factories.py new file mode 100644 index 0000000..425f886 --- /dev/null +++ b/sigimax/adapters_plotpy/factories.py @@ -0,0 +1,185 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Factories +------------------------ +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +__all__ = [ + "PlotPyAdapterFactory", + "create_adapter_from_object", + "get_adapter_factory", + "reset_adapter_factory", + "set_adapter_factory", +] + + +class PlotPyAdapterFactory: + """Resolve the PlotPy adapter class associated with an object or plot item. + + A derived application subclasses this factory to substitute its own + adapters or to support additional object types, then installs it with + :func:`set_adapter_factory` so that SigimaX components use it too. + """ + + def get_adapter_class(self, object_to_adapt) -> type: + """Return the adapter class for the given object. + + Args: + object_to_adapt: The object to adapt (signal, image or ROI) + + Returns: + The adapter class (instantiated with the object as sole argument) + + Raises: + TypeError: If the object type is not supported + """ + # pylint: disable=import-outside-toplevel + from sigima.objects import ( + CircularROI, + ImageObj, + ImageROI, + PolygonalROI, + RectangularROI, + SegmentROI, + SignalObj, + SignalROI, + ) + + 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, + ) + + if isinstance(object_to_adapt, SignalObj): + return SignalObjPlotPyAdapter + if isinstance(object_to_adapt, SignalROI): + return SignalROIPlotPyAdapter + if isinstance(object_to_adapt, SegmentROI): + return SegmentROIPlotPyAdapter + if isinstance(object_to_adapt, ImageObj): + return ImageObjPlotPyAdapter + if isinstance(object_to_adapt, RectangularROI): + return RectangularROIPlotPyAdapter + if isinstance(object_to_adapt, CircularROI): + return CircularROIPlotPyAdapter + if isinstance(object_to_adapt, PolygonalROI): + return PolygonalROIPlotPyAdapter + if isinstance(object_to_adapt, ImageROI): + return ImageROIPlotPyAdapter + raise TypeError(f"Unsupported object type: {type(object_to_adapt)}") + + def get_adapter_class_for_plot_item(self, plot_item) -> type: + """Return the single-ROI adapter class matching the given PlotPy item. + + Args: + plot_item: The PlotPy item to convert back into a single ROI + + Returns: + The single-ROI adapter class + + Raises: + TypeError: If the plot item type is not supported + """ + # pylint: disable=import-outside-toplevel + from plotpy.items import ( + AnnotatedCircle, + AnnotatedPolygon, + AnnotatedRectangle, + AnnotatedXRange, + ) + + from sigimax.adapters_plotpy.roi.image import ( + CircularROIPlotPyAdapter, + PolygonalROIPlotPyAdapter, + RectangularROIPlotPyAdapter, + ) + from sigimax.adapters_plotpy.roi.signal import SegmentROIPlotPyAdapter + + if isinstance(plot_item, AnnotatedXRange): + return SegmentROIPlotPyAdapter + if isinstance(plot_item, AnnotatedRectangle): + return RectangularROIPlotPyAdapter + if isinstance(plot_item, AnnotatedCircle): + return CircularROIPlotPyAdapter + if isinstance(plot_item, AnnotatedPolygon): + return PolygonalROIPlotPyAdapter + raise TypeError(f"Unsupported PlotPy item type: {type(plot_item)}") + + def create_adapter(self, object_to_adapt): + """Create an adapter instance for the given object. + + Args: + object_to_adapt: The object to adapt (signal, image or ROI) + + Returns: + An adapter instance + """ + return self.get_adapter_class(object_to_adapt)(object_to_adapt) + + +#: The currently active adapter factory. Defaults to the SigimaX base factory; +#: a derived application installs its own via :func:`set_adapter_factory`. All +#: SigimaX modules resolve adapters through :func:`get_adapter_factory` (never +#: by binding the base factory directly), so that a derived application's +#: adapters are honoured transparently regardless of import order. +_active_factory: PlotPyAdapterFactory = PlotPyAdapterFactory() + + +def get_adapter_factory() -> PlotPyAdapterFactory: + """Return the currently active adapter factory. + + Returns: + The active factory (the SigimaX base by default, or the one installed + by a derived application via :func:`set_adapter_factory`). + """ + return _active_factory + + +def set_adapter_factory(factory: PlotPyAdapterFactory) -> None: + """Install a derived application's adapter factory as the active one. + + Args: + factory: The factory to activate (typically a subclass of + :class:`PlotPyAdapterFactory`). + + Raises: + TypeError: If the factory is not a :class:`PlotPyAdapterFactory`. + """ + if not isinstance(factory, PlotPyAdapterFactory): + raise TypeError( + "Cannot install adapter factory: expected a PlotPyAdapterFactory " + f"instance, got {type(factory)}" + ) + global _active_factory # pylint: disable=global-statement + _active_factory = factory + + +def reset_adapter_factory() -> None: + """Restore the SigimaX base adapter factory as the active one.""" + global _active_factory # pylint: disable=global-statement + _active_factory = PlotPyAdapterFactory() + + +def create_adapter_from_object(object_to_adapt): + """Create an adapter for the given object to integrate with PlotPy + + Args: + object_to_adapt: The object to adapt (signal, image, ROI, or scalar result) + + Returns: + An adapter instance + """ + return get_adapter_factory().create_adapter(object_to_adapt) diff --git a/sigimax/adapters_plotpy/objects/__init__.py b/sigimax/adapters_plotpy/objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigimax/adapters_plotpy/objects/base.py b/sigimax/adapters_plotpy/objects/base.py new file mode 100644 index 0000000..87ad833 --- /dev/null +++ b/sigimax/adapters_plotpy/objects/base.py @@ -0,0 +1,219 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Base Object Module +--------------------------------- +""" + +from __future__ import annotations + +import abc +from typing import ( + TYPE_CHECKING, + Any, + Generic, + TypeVar, +) + +from guidata.dataset import update_dataset +from plotpy.items import ( + AnnotatedShape, +) +from sigima.objects.base import ( + ROI_KEY, + TypeObj, +) + +from sigimax.adapters_plotpy.annotations import PlotPyAnnotationAdapter +from sigimax.adapters_plotpy.base import ( + config_annotated_shape, + set_plot_item_editable, +) +from sigimax.config import get_conf + +if TYPE_CHECKING: + from plotpy.items import CurveItem, MaskedXYImageItem + from sigima.config import OptionField + +TypePlotItem = TypeVar("TypePlotItem", bound="CurveItem | MaskedXYImageItem") + + +class BaseObjPlotPyAdapter(Generic[TypeObj, TypePlotItem]): + """Object (signal/image) plot item adapter class""" + + DEFAULT_FMT = "s" # This is overriden in children classes + + @property + def conf_format(self) -> OptionField: + """Config option field holding the numeric format string. + + Overridden in the image adapter to return the image-specific field. + Resolved at runtime via ``get_conf()`` so the active (possibly + derived-application) configuration is honoured. + """ + return get_conf().sig_format + + def __init__(self, obj: TypeObj) -> None: + """Initialize the adapter with the object. + + Args: + obj: object (signal/image) + """ + self.obj = obj + # An empty format string in the configuration acts as a sentinel meaning + # "use the adapter's type-appropriate DEFAULT_FMT". + self.__default_options = { + "format": "%" + (self.conf_format.get() or self.DEFAULT_FMT), + "showlabel": get_conf().show_label.get(), + } + self.annotation_adapter = PlotPyAnnotationAdapter(obj) + + def get_obj_option(self, name: str) -> Any: + """Get object option value. + Args: + name: option name + + Returns: + Option value + """ + default = self.__default_options[name] + return self.obj.get_metadata_option(name, default) + + @abc.abstractmethod + def make_item(self, update_from: TypePlotItem | None = None) -> TypePlotItem: + """Make plot item from data. + + Args: + update_from: update + + Returns: + Plot item + """ + + @abc.abstractmethod + def update_item(self, item: TypePlotItem, data_changed: bool = True) -> None: + """Update plot item from data. + + Args: + item: plot item + data_changed: if True, data has changed + """ + + def add_annotations_from_items(self, items: list) -> None: + """Add object annotations (annotation plot items). + + Args: + items: annotation plot items + """ + # Use the new annotation adapter + self.annotation_adapter.add_items(items) + + def set_annotations_from_items(self, items: list) -> None: + """Set object annotations (annotation plot items), replacing any existing ones. + + Args: + items: annotation plot items + """ + # Use the new annotation adapter + self.annotation_adapter.set_items(items) + + @abc.abstractmethod + def add_label_with_title(self, title: str | None = None) -> None: + """Add label with title annotation + + Args: + title: title (if None, use object title) + """ + + def iterate_metadata_shape_items( + self, _key: str, _value: Any, _fmt: str, _lbl: bool + ): + """Hook: yield additional plot items for custom metadata entries. + + Override in subclasses to handle application-specific metadata + (e.g., geometry results, table results). Called once for each metadata + entry whose key is not ``ROI_KEY``. + + Args: + key: metadata key + value: metadata value + fmt: numeric format string (e.g. "%.3f") + lbl: whether to show labels + + Yields: + Plot items for this metadata entry + """ + return + yield # noqa: RET504 -- make this a generator + + def iterate_shape_items(self, editable: bool = False): + """Iterate over shape items encoded in metadata (if any). + + Args: + editable: if True, annotations are editable + + Yields: + Plot item + """ + fmt = self.get_obj_option("format") + lbl = self.get_obj_option("showlabel") + for key, value in self.obj.metadata.items(): + if key == ROI_KEY: + roi = self.obj.roi + if roi is not None: + # Delayed import to avoid circular dependency + # pylint: disable=import-outside-toplevel + from sigimax.adapters_plotpy.roi.factory import create_roi_adapter + + adapter = create_roi_adapter(roi) + yield from adapter.iterate_roi_items( + self.obj, fmt=fmt, lbl=lbl, editable=False + ) + else: + yield from self.iterate_metadata_shape_items(key, value, fmt, lbl) + # Use the new annotation adapter to get items + if self.obj.has_annotations(): + for item in self.annotation_adapter.get_items(): + if isinstance(item, AnnotatedShape): + config_annotated_shape(item, fmt, lbl) + set_plot_item_editable(item, editable) + yield item + + def update_plot_item_parameters(self, item: TypePlotItem) -> None: + """Update plot item parameters from object data/metadata + + Takes into account a subset of plot item parameters. Those parameters may + have been overriden by object metadata entries or other object data. The goal + is to update the plot item accordingly. + + This is *almost* the inverse operation of `update_metadata_from_plot_item`. + + Args: + item: plot item + """ + def_dict = get_conf().get_sigima_defaults(self.__class__.__name__[:3].lower()) + self.obj.set_metadata_options_defaults(def_dict, overwrite=False) + + # Subclasses have to override this method to update plot item parameters, + # then call this implementation of the method to update plot item. + update_dataset(item.param, self.obj.get_metadata_options()) + item.param.update_item(item) + if item.selected: + item.select() + + def update_metadata_from_plot_item(self, item: TypePlotItem) -> None: + """Update metadata from plot item. + + Takes into account a subset of plot item parameters. Those parameters may + have been modified by the user through the plot item GUI. The goal is to + update the metadata accordingly. + + This is *almost* the inverse operation of `update_plot_item_parameters`. + + Args: + item: plot item + """ + def_dict = get_conf().get_sigima_defaults(self.__class__.__name__[:3].lower()) + for key in def_dict: + if hasattr(item.param, key): # In case the PlotPy version is not up-to-date + self.obj.set_metadata_option(key, getattr(item.param, key)) diff --git a/sigimax/adapters_plotpy/objects/image.py b/sigimax/adapters_plotpy/objects/image.py new file mode 100644 index 0000000..8a8ddae --- /dev/null +++ b/sigimax/adapters_plotpy/objects/image.py @@ -0,0 +1,166 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Image Module +--------------------------- +""" + +# 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.dataset import update_dataset +from plotpy.builder import make +from plotpy.items import MaskedXYImageItem +from sigima.objects import ImageObj + +from sigimax.adapters_plotpy.objects.base import ( + BaseObjPlotPyAdapter, +) +from sigimax.config import get_conf + +if TYPE_CHECKING: + from sigima.config import OptionField + + +def get_obj_coords(obj: ImageObj) -> tuple[np.ndarray, np.ndarray]: + """Get object coordinates + + Args: + obj: image object + + Returns: + x and y coordinates (pixel centers, not edges) + """ + if obj.is_uniform_coords: + shape = obj.data.shape + # Generate coordinates for pixel centers, not edges + # For N pixels: centers are at x0, x0+dx, x0+2*dx, ..., x0+(N-1)*dx + xcoords = np.linspace(obj.x0, obj.x0 + obj.dx * (shape[1] - 1), shape[1]) + ycoords = np.linspace(obj.y0, obj.y0 + obj.dy * (shape[0] - 1), shape[0]) + else: + xcoords, ycoords = obj.xcoords, obj.ycoords + return xcoords, ycoords + + +class ImageObjPlotPyAdapter(BaseObjPlotPyAdapter[ImageObj, MaskedXYImageItem]): + """Image object plot item adapter class""" + + DEFAULT_FMT = ".1f" + + @property + def conf_format(self) -> OptionField: + """Image numeric format option field (resolved at runtime).""" + return get_conf().ima_format + + def update_plot_item_parameters(self, item: MaskedXYImageItem) -> None: + """Update plot item parameters from object data/metadata + + Takes into account a subset of plot item parameters. Those parameters may + have been overriden by object metadata entries or other object data. The goal + is to update the plot item accordingly. + + This is *almost* the inverse operation of `update_metadata_from_plot_item`. + + Args: + item: plot item + """ + o = self.obj + for axis in ("x", "y", "z"): + unit = getattr(o, axis + "unit") + fmt = r"%.1f" + if unit: + fmt = r"%.1f (" + unit + ")" + setattr(item.param, axis + "format", fmt) + item.set_xy(*get_obj_coords(o)) + zmin, zmax = item.get_lut_range() + if o.zscalemin is not None or o.zscalemax is not None: + zmin = zmin if o.zscalemin is None else o.zscalemin + zmax = zmax if o.zscalemax is None else o.zscalemax + item.set_lut_range([zmin, zmax]) + super().update_plot_item_parameters(item) + + def update_metadata_from_plot_item(self, item: MaskedXYImageItem) -> None: + """Update metadata from plot item. + + Takes into account a subset of plot item parameters. Those parameters may + have been modified by the user through the plot item GUI. The goal is to + update the metadata accordingly. + + This is *almost* the inverse operation of `update_plot_item_parameters`. + + Args: + item: plot item + """ + super().update_metadata_from_plot_item(item) + o = self.obj + # Updating the LUT range: + o.zscalemin, o.zscalemax = item.get_lut_range() + + def __viewable_data(self) -> np.ndarray: + """Return viewable data""" + data = self.obj.data.real + if np.any(np.isnan(data)): + data = np.nan_to_num(data, posinf=0, neginf=0) + return data + + def make_item( + self, update_from: MaskedXYImageItem | None = None + ) -> MaskedXYImageItem: + """Make plot item from data. + + Args: + update_from: update from plot item + + Returns: + Plot item + """ + data = self.__viewable_data() + item = make.maskedxyimage( + *get_obj_coords(self.obj), + data, + self.obj.maskdata, + title=self.obj.title, + colormap="viridis", + eliminate_outliers=get_conf().ima_eliminate_outliers.get(), + interpolation="nearest", + show_mask=True, + ) + if update_from is None: + self.update_plot_item_parameters(item) + else: + update_dataset(item.param, update_from.param) + item.param.update_item(item) + return item + + def update_item(self, item: MaskedXYImageItem, data_changed: bool = True) -> None: + """Update plot item from data. + + Args: + item: plot item + data_changed: if True, data has changed + """ + if data_changed: + # When data changes, let set_data() auto-calculate the LUT range from the + # new data (by not passing lut_range parameter). The subsequent call to + # update_plot_item_parameters() will override it if zscalemin/zscalemax + # are explicitly set in the object's metadata. + item.set_data(self.__viewable_data()) + item.set_mask(self.obj.maskdata) + item.param.label = self.obj.title + self.update_plot_item_parameters(item) + item.plot().update_colormap_axis(item) + + def add_label_with_title(self, title: str | None = None) -> None: + """Add label with title annotation + + Args: + title: title (if None, use image title) + """ + title = self.obj.title if title is None else title + if title: + label = make.label(title, (self.obj.x0, self.obj.y0), (10, 10), "TL") + self.add_annotations_from_items([label]) diff --git a/sigimax/adapters_plotpy/objects/signal.py b/sigimax/adapters_plotpy/objects/signal.py new file mode 100644 index 0000000..392b417 --- /dev/null +++ b/sigimax/adapters_plotpy/objects/signal.py @@ -0,0 +1,269 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Signal Module +---------------------------- +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Generator + +import numpy as np +from guidata.dataset import restore_dataset, update_dataset +from plotpy.builder import make +from plotpy.items import CurveItem, ErrorBarCurveItem +from sigima.objects import SignalObj + +from sigimax.adapters_plotpy.objects.base import ( + BaseObjPlotPyAdapter, +) +from sigimax.config import get_conf + + +class CurveStyles: + """Object to manage curve styles""" + + #: Curve colors + COLORS = ( + "#1f77b4", # muted blue + "#ff7f0e", # safety orange + "#2ca02c", # cooked asparagus green + "#d62728", # brick red + "#9467bd", # muted purple + "#8c564b", # chestnut brown + "#e377c2", # raspberry yogurt pink + "#7f7f7f", # gray + "#bcbd22", # curry yellow-green + "#17becf", # blue-teal + ) + #: Curve line styles + LINESTYLES = ("SolidLine", "DashLine", "DashDotLine", "DashDotDotLine") + + def __init__(self) -> None: + self.__suspend = False + self.curve_style = self.style_generator() + + @staticmethod + def style_generator() -> Generator[tuple[str, str], None, None]: + """Cycling through curve styles""" + while True: + for linestyle in CurveStyles.LINESTYLES: + for color in CurveStyles.COLORS: + yield (color, linestyle) + + def apply_style(self, item: CurveItem) -> None: + """Apply style to curve + + Args: + item: curve item + """ + if self.__suspend: + # Suspend mode: always apply the first style + color, linestyle = CurveStyles.COLORS[0], CurveStyles.LINESTYLES[0] + else: + color, linestyle = next(self.curve_style) + item.param.line.color = color + item.param.line.style = linestyle + item.param.symbol.marker = "NoSymbol" + # Note: line width is set separately via apply_line_width() + # to ensure it's always recalculated based on current data size and settings + + def reset_styles(self) -> None: + """Reset styles""" + self.curve_style = self.style_generator() + + @contextmanager + def alternative( + self, other_style_generator: Generator[tuple[str, str], None, None] + ) -> Generator[None, None, None]: + """Use an alternative style generator""" + old_style_generator = self.curve_style + self.curve_style = other_style_generator + yield + self.curve_style = old_style_generator + + @contextmanager + def suspend(self) -> Generator[None, None, None]: + """Suspend style generator""" + self.__suspend = True + yield + self.__suspend = False + + +CURVESTYLES = CurveStyles() # This is the unique instance of the CurveStyles class + + +def apply_line_width(item: CurveItem) -> None: + """Apply line width to curve item with smart clamping for large datasets + + Args: + item: curve item + """ + # Get data size + data_size = item.get_data()[0].size + + # Get configured line width + line_width = get_conf().sig_linewidth.get() + + # For large datasets, clamp linewidth to 1.0 for performance + # (thick lines cause ~10x rendering slowdown due to Qt raster engine) + threshold = get_conf().sig_linewidth_perfs_threshold.get() + if data_size > threshold and line_width > 1.0: + line_width = 1.0 + + # Apply the line width + item.param.line.width = line_width + + +def apply_downsampling(item: CurveItem, do_not_update: bool = False) -> None: + """Apply downsampling to curve item + + Args: + item: curve item + do_not_update: if True, do not update the item even if the downsampling + parameters have changed + """ + old_use_dsamp = item.param.use_dsamp + item.param.use_dsamp = False + if get_conf().sig_autodownsampling.get(): + nbpoints = item.get_data()[0].size + maxpoints = get_conf().sig_autodownsampling_maxpoints.get() + if nbpoints > 5 * maxpoints: + item.param.use_dsamp = True + item.param.dsamp_factor = nbpoints // maxpoints + if not do_not_update and old_use_dsamp != item.param.use_dsamp: + item.update_data() + + +class SignalObjPlotPyAdapter(BaseObjPlotPyAdapter[SignalObj, CurveItem]): + """Signal object plot item adapter class""" + + DEFAULT_FMT = "g" + # conf_format is inherited from the base adapter (returns get_conf().sig_format) + + def update_plot_item_parameters(self, item: CurveItem) -> None: + """Update plot item parameters from object data/metadata + + Takes into account a subset of plot item parameters. Those parameters may + have been overriden by object metadata entries or other object data. The goal + is to update the plot item accordingly. + + This is *almost* the inverse operation of `update_metadata_from_plot_item`. + + Args: + item: plot item + """ + update_dataset(item.param.line, self.obj.metadata) + update_dataset(item.param.symbol, self.obj.metadata) + super().update_plot_item_parameters(item) + + def update_metadata_from_plot_item(self, item: CurveItem) -> None: + """Update metadata from plot item. + + Takes into account a subset of plot item parameters. Those parameters may + have been modified by the user through the plot item GUI. The goal is to + update the metadata accordingly. + + This is *almost* the inverse operation of `update_plot_item_parameters`. + + Args: + item: plot item + """ + super().update_metadata_from_plot_item(item) + restore_dataset(item.param.line, self.obj.metadata) + restore_dataset(item.param.symbol, self.obj.metadata) + + def make_item(self, update_from: CurveItem | None = None) -> CurveItem: + """Make plot item from data. + + Args: + update_from: plot item to update from + + Returns: + Plot item + """ + o = self.obj + if len(o.xydata) in (2, 4): + assert isinstance(o.xydata, np.ndarray) + if len(o.xydata) == 2: # x, y signal + x, y = o.xydata + item = make.mcurve(x.real, y.real, label=o.title) + else: # x, y, dx, dy error bar signal + x, y, dx, dy = o.xydata + if o.dx is None and o.dy is None: # x, y signal with no error + item = make.mcurve(x.real, y.real, label=o.title) + elif o.dx is None: # x, y, dy error bar signal with y error + item = make.merror(x.real, y.real, dy.real, label=o.title) + else: # x, y, dx, dy error bar signal with x error + dy = np.zeros_like(y) if dy is None else dy + item = make.merror(x.real, y.real, dx.real, dy.real, label=o.title) + # Apply style (without linewidth, will be set separately) + CURVESTYLES.apply_style(item) + apply_downsampling(item, do_not_update=True) + # Apply linewidth with smart clamping based on actual data size + apply_line_width(item) + else: + raise RuntimeError("data not supported") + if update_from is None: + self.update_plot_item_parameters(item) + else: + update_dataset(item.param, update_from.param) + item.update_params() + return item + + def update_item(self, item: CurveItem, data_changed: bool = True) -> None: + """Update plot item from data. + + Args: + item: plot item + data_changed: if True, data has changed + """ + o = self.obj + if data_changed: + assert isinstance(o.xydata, np.ndarray) + if len(o.xydata) == 2: # x, y signal + x, y = o.xydata + assert isinstance(x, np.ndarray) and isinstance(y, np.ndarray) + item.set_data(x.real, y.real) + elif len(o.xydata) == 3: # x, y, dy error bar signal + x, y, dy = o.xydata + assert ( + isinstance(x, np.ndarray) + and isinstance(y, np.ndarray) + and isinstance(dy, np.ndarray) + ) + item.set_data(x.real, y.real, dy=dy.real) + elif len(o.xydata) == 4: # x, y, dx, dy error bar signal + x, y, dx, dy = o.xydata + assert ( + isinstance(x, np.ndarray) + and isinstance(y, np.ndarray) + and isinstance(dx, np.ndarray) + and isinstance(dy, np.ndarray) + ) + if isinstance(item, ErrorBarCurveItem): + item.set_data(x.real, y.real, dx.real, dy.real) + else: + # xydata has 4 rows but dx/dy are all NaN (no real + # error bars) — the plot item is a plain CurveItem + item.set_data(x.real, y.real) + item.param.label = o.title + apply_downsampling(item) + # Reapply linewidth with smart clamping (data size may have changed) + apply_line_width(item) + self.update_plot_item_parameters(item) + + def add_label_with_title(self, title: str | None = None) -> None: + """Add label with title annotation + + Args: + title: title (if None, use signal title) + """ + title = self.obj.title if title is None else title + if title: + label = make.label(title, "TL", (0, 0), "TL") + self.add_annotations_from_items([label]) diff --git a/sigimax/adapters_plotpy/roi/__init__.py b/sigimax/adapters_plotpy/roi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sigimax/adapters_plotpy/roi/base.py b/sigimax/adapters_plotpy/roi/base.py new file mode 100644 index 0000000..29d31b4 --- /dev/null +++ b/sigimax/adapters_plotpy/roi/base.py @@ -0,0 +1,146 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Base ROI Module +------------------------------ +""" + +import abc +from typing import TYPE_CHECKING, Generic, Iterator, Literal, TypeVar + +from plotpy.items import AnnotatedShape +from sigima.objects.base import ( + BaseROI, + TypeObj, + TypeROI, + TypeROIParam, + TypeSingleROI, + get_generic_roi_title, +) + +from sigimax.adapters_plotpy.base import config_annotated_shape + +if TYPE_CHECKING: + from plotpy.items import ( + AnnotatedCircle, + AnnotatedPolygon, + AnnotatedRectangle, + AnnotatedXRange, + ) + +TypeROIItem = TypeVar( + "TypeROIItem", + bound="AnnotatedXRange | AnnotatedPolygon | AnnotatedRectangle | AnnotatedCircle", +) + + +class BaseSingleROIPlotPyAdapter(Generic[TypeSingleROI, TypeROIItem], abc.ABC): + """Base class for single ROI plot item adapter + + Args: + single_roi: single ROI object + """ + + def __init__(self, single_roi: TypeSingleROI) -> None: + self.single_roi = single_roi + + @abc.abstractmethod + def to_plot_item(self, obj: TypeObj) -> TypeROIItem: + """Make ROI plot item from ROI. + + Args: + obj: object (signal/image), for physical-indices coordinates conversion + + Returns: + Plot item + """ + + @classmethod + @abc.abstractmethod + def from_plot_item(cls, item: TypeROIItem) -> TypeSingleROI: + """Create single ROI from plot item + + Args: + item: plot item + + Returns: + Single ROI + """ + + +def configure_roi_item( + item: TypeROIItem, + fmt: str, + lbl: bool, + editable: bool, + option: Literal["s", "i"], +): + """Configure ROI plot item. + + Args: + item: plot item + fmt: numeric format (e.g. "%.3f") + lbl: if True, show shape labels + editable: if True, make shape editable + option: shape style option ("s" for signal, "i" for image) + + Returns: + Plot item + """ + option += "/" + ("editable" if editable else "readonly") + if not editable: + if isinstance(item, AnnotatedShape): + config_annotated_shape( + item, fmt, lbl, "roi", option, show_computations=editable + ) + item.set_movable(False) + item.set_resizable(False) + item.set_readonly(True) + item.set_style("roi", option) + return item + + +class BaseROIPlotPyAdapter(Generic[TypeROI], abc.ABC): + """ROI plot item adapter class + + Args: + roi: ROI object + """ + + def __init__(self, roi: BaseROI[TypeObj, TypeSingleROI, TypeROIParam]) -> None: + self.roi = roi + + @abc.abstractmethod + def to_plot_item(self, single_roi: TypeSingleROI, obj: TypeObj) -> TypeROIItem: + """Make ROI plot item from single ROI + + Args: + single_roi: single ROI object + obj: object (signal/image), for physical-indices coordinates conversion + + Returns: + Plot item + """ + + def iterate_roi_items( + self, obj: TypeObj, fmt: str, lbl: bool, editable: bool = True + ) -> Iterator[TypeROIItem]: + """Iterate over ROI plot items associated to each single ROI composing + the object. + + Args: + obj: object (signal/image), for physical-indices coordinates conversion + fmt: format string + lbl: if True, add label + editable: if True, ROI is editable + + Yields: + Plot item + """ + for index, single_roi in enumerate(self.roi.single_rois): + roi_item = self.to_plot_item(single_roi, obj) + item = configure_roi_item( + roi_item, fmt, lbl, editable, option=self.roi.PREFIX + ) + item.setTitle(single_roi.title or get_generic_roi_title(index)) + yield item diff --git a/sigimax/adapters_plotpy/roi/factory.py b/sigimax/adapters_plotpy/roi/factory.py new file mode 100644 index 0000000..dd603fb --- /dev/null +++ b/sigimax/adapters_plotpy/roi/factory.py @@ -0,0 +1,46 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +ROI Adapter Factory +------------------- + +Factory functions for creating ROI adapters without circular imports. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sigima.objects.base import TypeObj + + +def create_roi_adapter(roi): + """Create ROI adapter from ROI object + + Args: + roi: ROI object + + Returns: + ROI adapter instance + """ + # pylint: disable=import-outside-toplevel + from sigimax.adapters_plotpy.factories import create_adapter_from_object + + return create_adapter_from_object(roi) + + +def create_single_roi_plot_item(single_roi, obj: TypeObj): + """Create plot item from single ROI + + Args: + single_roi: single ROI object + obj: object (signal/image), for physical-indices coordinates conversion + + Returns: + Plot item + """ + # pylint: disable=import-outside-toplevel + from sigimax.adapters_plotpy.factories import create_adapter_from_object + + return create_adapter_from_object(single_roi).to_plot_item(obj) diff --git a/sigimax/adapters_plotpy/roi/image.py b/sigimax/adapters_plotpy/roi/image.py new file mode 100644 index 0000000..a63583a --- /dev/null +++ b/sigimax/adapters_plotpy/roi/image.py @@ -0,0 +1,236 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Image ROI Module +------------------------------- +""" + +from __future__ import annotations + +import numpy as np +from plotpy.builder import make +from plotpy.items import AnnotatedCircle, AnnotatedPolygon, AnnotatedRectangle +from sigima.objects import CircularROI, ImageObj, ImageROI, PolygonalROI, RectangularROI +from sigima.tools import coordinates + +from sigimax.adapters_plotpy.coordutils import round_image_coords +from sigimax.adapters_plotpy.roi.base import ( + BaseROIPlotPyAdapter, + BaseSingleROIPlotPyAdapter, +) + + +def _vs(var: str, sub: str = "") -> str: + """Return variable name with subscript""" + txt = f"{var}" + if sub: + txt += f"{sub}" + return txt + + +class PolygonalROIPlotPyAdapter( + BaseSingleROIPlotPyAdapter[PolygonalROI, AnnotatedPolygon] +): + """Polygonal ROI plot item adapter + + Args: + single_roi: single ROI object + """ + + def to_plot_item(self, obj: ImageObj) -> AnnotatedPolygon: + """Make and return the annnotated polygon associated to ROI + + Args: + obj: object (image), for physical-indices coordinates conversion + """ + + def info_callback(item: AnnotatedPolygon) -> str: + """Return info string for circular ROI""" + xc, yc = item.get_center() + if self.single_roi.indices: + xc, yc = obj.physical_to_indices([xc, yc]) + return "
".join( + [ + f"({_vs('x', 'c')}, {_vs('y', 'c')}) = ({xc:g}, {yc:g})", + ] + ) + + coords = np.array(self.single_roi.get_physical_coords(obj)) + points = coords.reshape(-1, 2) + item = AnnotatedPolygon(points) + item.set_info_callback(info_callback) + item.annotationparam.title = self.single_roi.title + item.annotationparam.update_item(item) + item.set_style("plot", "shape/drag") + return item + + @classmethod + def from_plot_item( + cls, item: AnnotatedPolygon, obj: ImageObj | None = None + ) -> PolygonalROI: + """Create ROI from plot item + + Args: + item: plot item + obj: image object for coordinate rounding (optional) + """ + coords = item.get_points().flatten().tolist() + # Round coordinates to appropriate precision + if obj is not None: + coords = round_image_coords(obj, coords) + title = str(item.title().text()) + return PolygonalROI(coords, False, title) + + +class RectangularROIPlotPyAdapter( + BaseSingleROIPlotPyAdapter[RectangularROI, AnnotatedRectangle] +): + """Rectangular ROI plot item adapter + + Args: + single_roi: single ROI object + """ + + def to_plot_item(self, obj: ImageObj) -> AnnotatedRectangle: + """Make and return the annnotated rectangle associated to ROI + + Args: + obj: object (image), for physical-indices coordinates conversion + """ + + def info_callback(item: AnnotatedRectangle) -> str: + """Return info string for rectangular ROI""" + x0, y0, x1, y1 = item.get_rect() + if self.single_roi.indices: + x0, y0, x1, y1 = obj.physical_to_indices([x0, y0, x1, y1]) + x0, y0, dx, dy = self.single_roi.rect_to_coords(x0, y0, x1, y1) + return "
".join( + [ + f"({_vs('x', '0')}, {_vs('y', '0')}) = ({x0:g}, {y0:g})", + f"{_vs('Δx')} × {_vs('Δy')} = {dx:g} × {dy:g}", + ] + ) + + x0, y0, dx, dy = self.single_roi.get_physical_coords(obj) + x1, y1 = x0 + dx, y0 + dy + item: AnnotatedRectangle = make.annotated_rectangle( + x0, y0, x1, y1, title=self.single_roi.title + ) + item.set_info_callback(info_callback) + param = item.label.labelparam + param.anchor = "BL" + param.xc, param.yc = 5, -5 + param.update_item(item.label) + return item + + @classmethod + def from_plot_item( + cls, item: AnnotatedRectangle, obj: ImageObj | None = None + ) -> RectangularROI: + """Create ROI from plot item + + Args: + item: plot item + obj: image object for coordinate rounding (optional) + """ + rect = item.get_rect() + coords = RectangularROI.rect_to_coords(*rect) + # Round coordinates to appropriate precision + if obj is not None: + coords = round_image_coords(obj, coords) + title = str(item.title().text()) + return RectangularROI(coords, False, title) + + +class CircularROIPlotPyAdapter( + BaseSingleROIPlotPyAdapter[CircularROI, AnnotatedCircle] +): + """Circular ROI plot item adapter + + Args: + single_roi: single ROI object + """ + + def to_plot_item(self, obj: ImageObj) -> AnnotatedCircle: + """Make and return the annnotated circle associated to ROI + + Args: + obj: object (image), for physical-indices coordinates conversion + """ + + def info_callback(item: AnnotatedCircle) -> str: + """Return info string for circular ROI""" + x0, y0, x1, y1 = item.get_rect() + if self.single_roi.indices: + x0, y0, x1, y1 = obj.physical_to_indices([x0, y0, x1, y1]) + xc, yc, r = self.single_roi.rect_to_coords(x0, y0, x1, y1) + return "
".join( + [ + f"({_vs('x', 'c')}, {_vs('y', 'c')}) = ({xc:g}, {yc:g})", + f"{_vs('r')} = {r:g}", + ] + ) + + xc, yc, r = self.single_roi.get_physical_coords(obj) + x0, y0, x1, y1 = coordinates.circle_to_diameter(xc, yc, r) + item = AnnotatedCircle(x0, y0, x1, y1) + item.set_info_callback(info_callback) + item.annotationparam.title = self.single_roi.title + item.annotationparam.update_item(item) + item.set_style("plot", "shape/drag") + return item + + @classmethod + def from_plot_item( + cls, item: AnnotatedCircle, obj: ImageObj | None = None + ) -> CircularROI: + """Create ROI from plot item + + Args: + item: plot item + obj: image object for coordinate rounding (optional) + """ + rect = item.get_rect() + coords = CircularROI.rect_to_coords(*rect) + # Round coordinates to appropriate precision + # For circular ROI: [xc, yc, r] - round center (xc, yc) as pair, then radius + if obj is not None: + xc, yc, r = coords + # Round center coordinates + xc_rounded, yc_rounded = round_image_coords(obj, [xc, yc]) + # Round radius using average of X and Y precision + # For radius, we use the X precision (could also average X and Y) + r_rounded = round_image_coords(obj, [r, 0])[0] + coords = [xc_rounded, yc_rounded, r_rounded] + title = str(item.title().text()) + return CircularROI(coords, False, title) + + +class ImageROIPlotPyAdapter(BaseROIPlotPyAdapter[ImageROI]): + """Image ROI plot item adapter class + + Args: + roi: ROI object + """ + + def to_plot_item( + self, + single_roi: PolygonalROI | RectangularROI | CircularROI, + obj: ImageObj, + ) -> AnnotatedCircle | AnnotatedRectangle | AnnotatedPolygon: + """Make ROI plot item from single ROI + + Args: + single_roi: single ROI object + obj: object (signal/image), for physical-indices coordinates conversion + + Returns: + Plot item + """ + if isinstance(single_roi, PolygonalROI): + return PolygonalROIPlotPyAdapter(single_roi).to_plot_item(obj) + if isinstance(single_roi, RectangularROI): + return RectangularROIPlotPyAdapter(single_roi).to_plot_item(obj) + if isinstance(single_roi, CircularROI): + return CircularROIPlotPyAdapter(single_roi).to_plot_item(obj) + raise TypeError(f"Invalid ROI type {type(single_roi)}") diff --git a/sigimax/adapters_plotpy/roi/signal.py b/sigimax/adapters_plotpy/roi/signal.py new file mode 100644 index 0000000..b1fd612 --- /dev/null +++ b/sigimax/adapters_plotpy/roi/signal.py @@ -0,0 +1,364 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +PlotPy Adapter Signal ROI Module +-------------------------------- +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Iterator + +import numpy as np +from plotpy.items import AnnotatedXRange +from plotpy.items.shape.range import XRangeSelection +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from sigima.objects import SegmentROI, SignalObj, SignalROI +from sigima.objects.base import GENERIC_ROI_TITLE_REGEXP, get_generic_roi_title + +from sigimax.adapters_plotpy.coordutils import round_signal_coords +from sigimax.adapters_plotpy.roi.base import ( + BaseROIPlotPyAdapter, + BaseSingleROIPlotPyAdapter, + configure_roi_item, +) + +if TYPE_CHECKING: + import qwt.scale_map + from qtpy.QtCore import QRectF + from qtpy.QtGui import QPainter + + +# Color palette used to cycle through ROI fill colors when several ROIs are +# defined on the same signal. Inspired by the matplotlib ``tab10`` palette so +# that consecutive ROIs are easily distinguishable. +_ROI_FILL_COLORS: tuple[str, ...] = ( + "#1f77b4", # blue + "#ff7f0e", # orange + "#2ca02c", # green + "#d62728", # red + "#9467bd", # purple + "#8c564b", # brown + "#e377c2", # pink + "#7f7f7f", # grey + "#bcbd22", # yellow-green + "#17becf", # cyan +) + +# Alpha (0..255) used for the translucent fill of ROI rectangles +_ROI_FILL_ALPHA: int = 90 + + +def roi_color_for_index(index: int) -> QG.QColor: + """Return the ROI fill color for the given ROI index (cycles through the + predefined palette).""" + name = _ROI_FILL_COLORS[index % len(_ROI_FILL_COLORS)] + color = QG.QColor(name) + color.setAlpha(_ROI_FILL_ALPHA) + return color + + +class _CurveClippedXRangeSelection(XRangeSelection): + """X-range selection whose translucent fill is clipped vertically to the + underlying signal curve (instead of filling the whole canvas height). + + The fill color can additionally be overridden on a per-instance basis to + enable color cycling between sibling ROIs displayed on the same signal. + """ + + def __init__( + self, + _min: float | None = None, + _max: float | None = None, + shapeparam=None, + ) -> None: + super().__init__(_min, _max, shapeparam) + self._curve_x: np.ndarray | None = None + self._curve_y: np.ndarray | None = None + self._fill_color: QG.QColor | None = None + + def set_signal_curve(self, x: np.ndarray, y: np.ndarray) -> None: + """Attach the signal curve coordinates used to clip the fill area.""" + self._curve_x = np.asarray(x, dtype=float) + self._curve_y = np.asarray(y, dtype=float) + + def set_fill_color(self, color: QG.QColor) -> None: + """Override the fill color for this ROI instance.""" + self._fill_color = QG.QColor(color) + + def _build_curve_polygon( # pylint: disable=too-many-return-statements + self, + xMap: qwt.scale_map.QwtScaleMap, # pylint: disable=invalid-name + yMap: qwt.scale_map.QwtScaleMap, # pylint: disable=invalid-name + rct: QRectF, + ) -> QG.QPolygonF | None: + """Build a polygon that follows the signal curve between ``self._min`` + and ``self._max``, with a flat baseline at y=0 (clamped to the visible + canvas area when y=0 lies outside the current axis range, or fall back + to the canvas bottom when the y-axis uses a logarithmic scale). + """ + if self._curve_x is None or self._curve_y is None: + return None + x_arr = self._curve_x + y_arr = self._curve_y + if x_arr.size < 2: + return None + # Filter non-finite samples (NaN/Inf) which would produce invalid + # polygon vertices via ``np.interp``. + finite = np.isfinite(x_arr) & np.isfinite(y_arr) + if not finite.all(): + x_arr = x_arr[finite] + y_arr = y_arr[finite] + if x_arr.size < 2: + return None + xmin, xmax = self._min, self._max + if xmin is None or xmax is None: + return None + if xmin > xmax: + xmin, xmax = xmax, xmin + # Make sure x_arr is sorted (required by np.interp at the boundaries) + if not np.all(np.diff(x_arr) >= 0): + order = np.argsort(x_arr) + x_arr = x_arr[order] + y_arr = y_arr[order] + # Restrict to the actual data extent to avoid drawing the polygon + # outside the signal definition domain. + x0 = max(xmin, float(x_arr[0])) + x1 = min(xmax, float(x_arr[-1])) + if x1 <= x0: + return None + mask = (x_arr >= x0) & (x_arr <= x1) + xs_in = x_arr[mask] + ys_in = y_arr[mask] + y_left = float(np.interp(x0, x_arr, y_arr)) + y_right = float(np.interp(x1, x_arr, y_arr)) + xs = np.concatenate(([x0], xs_in, [x1])) + ys = np.concatenate(([y_left], ys_in, [y_right])) + # Remove duplicate boundary samples if interpolation hit an existing + # data point exactly. + keep = np.concatenate(([True], np.diff(xs) > 0)) + xs = xs[keep] + ys = ys[keep] + if xs.size < 2: + return None + # Baseline: y=0 in axis coordinates for linear scales, clamped to the + # visible canvas area. On a log scale, y=0 has no meaning, so fall + # back to the canvas bottom. + baseline_y = self._compute_baseline_y(yMap, rct) + if baseline_y is None: + return None + polygon = QG.QPolygonF() + polygon.append(QC.QPointF(xMap.transform(xs[0]), baseline_y)) + for xv, yv in zip(xs, ys): + polygon.append(QC.QPointF(xMap.transform(xv), yMap.transform(yv))) + polygon.append(QC.QPointF(xMap.transform(xs[-1]), baseline_y)) + return polygon + + def _compute_baseline_y( + self, + yMap: qwt.scale_map.QwtScaleMap, # pylint: disable=invalid-name + rct: QRectF, + ) -> float | None: + """Return the canvas y-coordinate of the polygon baseline. + + Uses y=0 when the y-axis is linear, falls back to the canvas bottom + when the axis is logarithmic (where y=0 is undefined). + """ + plot = self.plot() + is_log_y = plot is not None and plot.get_axis_scale(self.yAxis()) == "log" + if is_log_y: + return rct.bottom() + baseline_y = yMap.transform(0.0) + if not np.isfinite(baseline_y): + return rct.bottom() + return max(rct.top(), min(rct.bottom(), baseline_y)) + + def draw( + self, + painter: QPainter, + xMap: qwt.scale_map.QwtScaleMap, + yMap: qwt.scale_map.QwtScaleMap, + canvasRect: QRectF, + ) -> None: + """Draw the ROI: filled polygon clipped to the curve (or a fallback + rectangle if no curve information is available), surrounded by the + usual handle decoration (vertical edges and central dashed line).""" + plot = self.plot() + if not plot: + return + if self.selected: + pen = self.sel_pen + sym = self.sel_symbol + else: + pen = self.pen + sym = self.symbol + + # Build the fallback rectangle covering the canvas height + rct = QC.QRectF(plot.canvas().contentsRect()) + rct.setLeft(xMap.transform(self._min)) + rct.setRight(xMap.transform(self._max)) + + # Choose the brush: per-instance color override has priority. The pen + # is preserved (so selection style — width/dash — keeps its visual + # role) and only its color is replaced to match the fill color. + if self._fill_color is not None: + brush = QG.QBrush(self._fill_color) + edge_color = QG.QColor(self._fill_color) + edge_color.setAlpha(255) + pen = QG.QPen(pen) + pen.setColor(edge_color) + else: + brush = self.brush + + polygon = self._build_curve_polygon(xMap, yMap, rct) + painter.save() + painter.setPen(QC.Qt.NoPen) + painter.setBrush(brush) + if polygon is not None: + painter.drawPolygon(polygon) + else: + painter.fillRect(rct, brush) + painter.restore() + + # Vertical edges at xmin and xmax (full canvas height) + painter.setPen(pen) + painter.drawLine(rct.topLeft(), rct.bottomLeft()) + painter.drawLine(rct.topRight(), rct.bottomRight()) + + # Dashed central line + dash = QG.QPen(pen) + dash.setStyle(QC.Qt.DashLine) + dash.setWidth(1) + painter.setPen(dash) + cx = rct.center().x() + painter.drawLine(QC.QPointF(cx, rct.top()), QC.QPointF(cx, rct.bottom())) + + if self.can_resize() and not self.is_readonly(): + painter.setPen(pen) + x0, x1, y = self.get_handles_pos() + sym.drawSymbol(painter, QC.QPointF(x0, y)) + sym.drawSymbol(painter, QC.QPointF(x1, y)) + + +class _CurveClippedAnnotatedXRange(AnnotatedXRange): # pylint: disable=abstract-method + """Annotated X-range selection whose underlying shape is a + :class:`_CurveClippedXRangeSelection` (curve-clipped fill + per-instance + color). + + ``get_tr_size`` is intentionally not overridden: ``AnnotatedXRange`` (from + PlotPy) does not override it either, so the abstract-method warning is a + false positive inherited from upstream.""" + + SHAPE_CLASS = _CurveClippedXRangeSelection + + +class SegmentROIPlotPyAdapter(BaseSingleROIPlotPyAdapter[SegmentROI, AnnotatedXRange]): + """Segment ROI plot item adapter + + Args: + coords: ROI coordinates (xmin, xmax) + title: ROI title + """ + + def to_plot_item( + self, + obj: SignalObj, + fill_color: QG.QColor | None = None, + ) -> AnnotatedXRange: + """Make and return the annotated segment associated with the ROI + + Args: + obj: object (signal), for physical-indices coordinates conversion + fill_color: optional fill color override (used for color cycling + between sibling ROIs) + """ + xmin, xmax = self.single_roi.get_physical_coords(obj) + item = _CurveClippedAnnotatedXRange(xmin, xmax) + item.setTitle(self.single_roi.title) + # Apply default range style so pen/brush/symbol attributes are set + item.shape.set_style("plot", "range") + # Provide curve coordinates for clipped rendering + x, y = obj.xydata + if x is not None and y is not None: + item.shape.set_signal_curve(x, y) + if fill_color is not None: + item.shape.set_fill_color(fill_color) + return item + + @classmethod + def from_plot_item( + cls, item: AnnotatedXRange, obj: SignalObj | None = None + ) -> SegmentROI: + """Create ROI from plot item + + Args: + item: plot item + obj: signal object for coordinate rounding (optional) + + Returns: + ROI + """ + if not isinstance(item, AnnotatedXRange): + raise TypeError("Invalid plot item type") + coords = sorted(item.get_range()) + # Round coordinates to appropriate precision + if obj is not None: + coords = round_signal_coords(obj, coords) + title = str(item.title().text()) + return SegmentROI(coords, False, title) + + +class SignalROIPlotPyAdapter(BaseROIPlotPyAdapter[SignalROI]): + """Signal ROI plot item adapter class + + Args: + roi: ROI object + """ + + def to_plot_item( + self, + single_roi: SegmentROI, + obj: SignalObj, + fill_color: QG.QColor | None = None, + ) -> AnnotatedXRange: + """Make ROI plot item from single ROI + + Args: + single_roi: single ROI object + obj: object (signal/image), for physical-indices coordinates conversion + fill_color: optional fill color override (used for color cycling) + + Returns: + Plot item + """ + return SegmentROIPlotPyAdapter(single_roi).to_plot_item( + obj, fill_color=fill_color + ) + + def iterate_roi_items( + self, + obj: SignalObj, + fmt: str, + lbl: bool, + editable: bool = True, + ) -> Iterator[AnnotatedXRange]: + """Iterate over ROI plot items, applying alternating fill colors so + that several ROIs displayed on the same signal can be visually + distinguished. The color cycling index is derived from the trailing + digits of the ROI title (``ROI``) when available, falling back to + the position of the ROI in the list otherwise; this keeps the + per-ROI color stable across deletions/reorderings. + """ + for index, single_roi in enumerate(self.roi.single_rois): + title = single_roi.title or get_generic_roi_title(index) + match = re.match(GENERIC_ROI_TITLE_REGEXP, title) + color_index = int(match.group(1)) if match is not None else index + color = roi_color_for_index(color_index) + roi_item = self.to_plot_item(single_roi, obj, fill_color=color) + item = configure_roi_item( + roi_item, fmt, lbl, editable, option=self.roi.PREFIX + ) + item.setTitle(title) + yield item diff --git a/sigimax/app.py b/sigimax/app.py new file mode 100644 index 0000000..a674ed5 --- /dev/null +++ b/sigimax/app.py @@ -0,0 +1,144 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Application launcher +==================== + +The :mod:`sigimax.app` module provides helper functions to create and run +SigimaX-derived applications with optional splash screen support. + +Derived applications typically call :func:`create` (or :func:`run`) with +their own :class:`~sigimax.mainwindow.SGMXMainWindow` subclass and an +optional :class:`~sigimax.widgets.splashscreen.SplashScreenConfig`. + +Basic usage:: + + from sigimax.app import run + from sigimax.widgets.splashscreen import SplashScreenConfig + from myapp.main import MyAppMainWindow + + run( + window_class=MyAppMainWindow, + splash_config=SplashScreenConfig( + image_path="myapp/data/splash.png", + app_name="MyApp", + app_version="1.0.0", + ), + ) + +.. autofunction:: create +.. autofunction:: run +""" + +from __future__ import annotations + +from typing import TypeVar + +from qtpy import QtWidgets as QW + +from sigimax.config import get_conf +from sigimax.env import execenv +from sigimax.mainwindow import SGMXMainWindow +from sigimax.utils.qthelpers import sigimax_app_context +from sigimax.widgets.splashscreen import SigimaXSplashScreen, SplashScreenConfig + +_WindowT = TypeVar("_WindowT", bound=SGMXMainWindow) + + +def create( + window_class: type[_WindowT] = SGMXMainWindow, + splash: bool = True, + splash_config: SplashScreenConfig | None = None, + console: bool | None = None, + h5files: list[str] | None = None, + size: tuple[int, int] | None = None, +) -> _WindowT: + """Create and show a SigimaX application window. + + This is the recommended entry point for derived applications. It handles + the splash screen lifecycle around the (potentially heavy) window + initialization. + + Args: + window_class: The main window class to instantiate. Defaults to + :class:`~sigimax.mainwindow.SGMXMainWindow`. + splash: If ``True``, show a splash screen during initialization. + splash_config: Explicit splash screen configuration. If ``None`` and + *splash* is ``True``, the configuration is built from + :data:`sigimax.config.CONF`. + console: If ``True``, enable the embedded console. ``None`` reads + from :data:`sigimax.config.CONF`. + h5files: Optional list of HDF5 file paths to open after startup. + size: Optional ``(width, height)`` tuple for the window size. + + Returns: + The initialized and visible main window instance. + """ + splashscreen: SigimaXSplashScreen | None = None + + if splash: + config = splash_config or SplashScreenConfig.from_conf() + if config.is_enabled: + splashscreen = SigimaXSplashScreen(config) + splashscreen.show() + splashscreen.show_message("Initializing...") + QW.QApplication.processEvents() + + # --- Heavy initialization --- + window = window_class(console=console) + + if splashscreen is not None: + splashscreen.show_message("Loading workspace...") + QW.QApplication.processEvents() + + if size is not None: + width, height = size + window.resize(width, height) + + if splashscreen is not None: + splashscreen.finish(window) + + if get_conf().window_maximized.get(): + window.showMaximized() + else: + window.showNormal() + + if h5files is not None: + window.open_h5_files(h5files, import_all=True) + + return window + + +def run( + window_class: type[_WindowT] = SGMXMainWindow, + splash: bool = True, + splash_config: SplashScreenConfig | None = None, + console: bool | None = None, + h5files: list[str] | None = None, + size: tuple[int, int] | None = None, +) -> None: + """Create and run a SigimaX application with an event loop. + + Convenience wrapper around :func:`create` that manages the + :func:`~sigimax.utils.qthelpers.sigimax_app_context` lifecycle. + + Args: + window_class: The main window class to instantiate. + splash: If ``True``, show a splash screen during initialization. + splash_config: Explicit splash screen configuration. + console: If ``True``, enable the embedded console. + h5files: Optional list of HDF5 file paths to open. + size: Optional ``(width, height)`` tuple for the window size. + """ + execenv.parse_args() + with sigimax_app_context(exec_loop=True): + window = create( + window_class=window_class, + splash=splash, + splash_config=splash_config, + console=console, + h5files=h5files, + size=size, + ) + QW.QApplication.processEvents() + window.execute_post_show_actions() diff --git a/sigimax/config.py b/sigimax/config.py new file mode 100644 index 0000000..6c8fe4c --- /dev/null +++ b/sigimax/config.py @@ -0,0 +1,2309 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX Configuration Options System (:mod:`sigimax.config`) +------------------------------------------------------------ + +Sigima-style in-memory option system for SigimaX-based GUI applications. + +This module provides a clean, extensible configuration system following the same +pattern as :mod:`sigima.config` (``OptionField`` with ``get``/``set``/``context``), +but tailored for GUI applications built on SigimaX. + +Design principles: + +- **Simple API**: Options are accessed via ``CONF.color_mode.get()`` and + ``CONF.color_mode.set("dark")``. +- **Optional initialization defaults**: ``CONF.color_mode.get("auto")`` sets + and returns ``"auto"`` only when the option has not been explicitly + initialized or loaded. +- **Context managers**: Temporarily override options with + ``with CONF.fft_shift_enabled.context(False): ...``. +- **Extensible via subclassing**: Derived applications (like DataLab) subclass + :class:`SigimaXOptions` to add their own options. +- **Optional JSON file persistence**: For GUI apps that need to persist user + preferences across sessions. + +Typical usage: + +.. code-block:: python + + from sigimax.config import CONF as Conf + + # Get an option value + colormap = Conf.ima_def_colormap.get() + + # Set an option value + Conf.ima_def_colormap.set("gray") + + # Temporarily override an option + with Conf.fft_shift_enabled.context(False): + # FFT shift is disabled in this block + ... + + # Save/load from JSON file (for GUI persistence) + Conf.save() # saves to default config path + Conf.load() # loads from default config path + +Extending for a derived application: + +.. code-block:: python + + from sigimax.options import SigimaXOptions + + class MyAppOptions(SigimaXOptions): + def __init__(self): + super().__init__() + self.my_custom_option = TypedOptionField( + self, "my_custom_option", default=42, + expected_type=int, + description="My custom option for MyApp", + ) + + options = MyAppOptions() + +.. autoclass:: SigimaXOptions + :members: +.. autoclass:: AppOptionsContainer + :members: +.. autoclass:: OptionField + :members: +.. autoclass:: TypedOptionField + :members: +.. autoclass:: ImageIOOptionField + :members: +.. autoclass:: EnumOptionField + :members: +.. autoclass:: TupleOptionField + :members: +.. autoclass:: FontOptionField + :members: +.. autoclass:: ConfigPathOptionField + :members: +.. autoclass:: WorkingDirOptionField + :members: +.. autoclass:: FormatStringOptionField + :members: +.. autoclass:: DataSetOptionField + :members: +.. autofunction:: get_conf +.. autofunction:: set_conf +.. autofunction:: reset_conf +""" + +from __future__ import annotations + +import json +import os +import os.path as osp +import sys +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import guidata.dataset as gds +from guidata import configtools +from plotpy import config as plotpy_config +from plotpy.config import CONF as PLOTPY_CONF +from sigima.config import OptionsContainer +from sigima.config import options as sigima_options +from sigima.proc.title_formatting import ( + PlaceholderTitleFormatter, + set_default_title_formatter, +) + +from sigimax.utils import conf as _conf_module # For config dir resolution + +if TYPE_CHECKING: + from qtpy import QtGui as QG + +# Module-level constants +MOD_TITLE = "SigimaX" +MOD_NAME = "sigimax" + +# Configure Sigima to use placeholder title formatting +set_default_title_formatter(PlaceholderTitleFormatter()) + +# Configure guidata translation and icons paths for SigimaX +_ = configtools.get_translation(MOD_NAME) +configtools.add_image_module_path(MOD_NAME, osp.join("data", "icons")) +DATAPATH = configtools.get_module_data_path(MOD_NAME, "data") + +# Other Module-level constants +MOD_DESC = _("""SigimaX is a GUI library working with Sigima and PlotPyStack. + It provides a App configuration system, a generic MainWindow class and a + set of widgets to build applications on top of Sigima and PlotPyStack.""") + +DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") +if DEBUG: + print("*** DEBUG mode *** [Reset configuration file, do not redirect std I/O]") + +TEST_SEGFAULT_ERROR = len(os.environ.get("TEST_SEGFAULT_ERROR", "")) > 0 +if TEST_SEGFAULT_ERROR: + print('*** TEST_SEGFAULT_ERROR mode *** [Enabling test action in "?" menu]') +# DATETIME_FORMAT = "%d/%m/%Y - %H:%M:%S" + + +def get_old_log_fname(fname): + """Return old log fname from current log fname""" + return osp.splitext(fname)[0] + ".1.log" + + +def is_frozen(module_name: str) -> bool: + """Test if module has been frozen (py2exe/cx_Freeze/pyinstaller) + + Args: + module_name (str): module name + + Returns: + bool: True if module has been frozen (py2exe/cx_Freeze/pyinstaller) + """ + datapath = configtools.get_module_path(module_name) + parentdir = osp.normpath(osp.join(datapath, osp.pardir)) + return not osp.isfile(__file__) or osp.isfile(parentdir) # library.zip + + +IS_FROZEN = is_frozen(MOD_NAME) + + +def get_mod_source_dir() -> str | None: + """Return module source directory + + Returns: + str | None: module source directory, or None if not found + """ + if IS_FROZEN: + devdir = osp.abspath(osp.join(sys.prefix, os.pardir, os.pardir)) + else: + devdir = osp.abspath(osp.join(osp.dirname(__file__), os.pardir)) + if osp.isfile(osp.join(devdir, MOD_NAME, "__init__.py")): + return devdir + # Unhandled case (this should not happen, but just in case): + return None + + +# --------------------------------------------------------------------------- +# Custom OptionField subclasses for GUI application options +# --------------------------------------------------------------------------- + + +NO_DEFAULT = object() + + +class OptionField: + """SigimaX option field supporting optional default initialization.""" + + #: Suffixes of the storage keys occupied by the field, for backends storing + #: one scalar per key (e.g. INI). Empty means a single key. + storage_suffixes: tuple[str, ...] = () + + #: Whether the serialized value must be escaped by format-string-sensitive + #: backends (e.g. ``%`` doubling for ConfigParser). + storage_escape: bool = False + + def __init__( + self, + container: OptionsContainer, + name: str, + default: Any, + description: str = "", + category: str = "", + storage_key: str = "", + runtime: bool = False, + ) -> None: + self._container = container + self.name = name + self.check(default) + self._value = default + self.description = description + self.category = category + #: Storage key overriding the one derived from the option name. + self.storage_key = storage_key + #: Value shared between processes through the storage backend: its owner + #: persists it individually, bulk saves must leave it untouched. + self.runtime = runtime + self._is_initialized = False + + def check(self, value: Any) -> None: # pylint: disable=unused-argument + """Validate the configured value in specialized fields.""" + + def get(self, default: Any = NO_DEFAULT) -> Any: + """Return the value, initializing a missing option from ``default``. + + Args: + default: Optional value used when the option is not initialized. + ``None`` is returned without initializing the option. + Returns: + The exact supplied default after initialization, or the current value. + """ + is_initialized = getattr( + self._container, "is_option_initialized", lambda _name: self._is_initialized + ) + if ( + default is not NO_DEFAULT + and default is not None + and not is_initialized(self.name) + ): + self.set(default) + return default + return self._value + + def set(self, value: Any) -> None: + """Set the value and mark the option as initialized.""" + self.check(value) + self._value = value + self.mark_initialized() + self._container.option_changed(self.name) + + def mark_initialized(self) -> None: + """Mark a value assigned outside the standard setter as initialized.""" + self._is_initialized = True + mark_initialized = getattr(self._container, "mark_option_initialized", None) + if mark_initialized is not None: + mark_initialized(self.name) + + def to_storage(self) -> Any: + """Return the value in a serialization-friendly form. + + Fields whose :meth:`get` transforms the stored value must override this + (and :meth:`from_storage`) to avoid a lossy ``get``/``set`` round-trip. + + Returns: + A JSON-compatible representation of the value. When + :attr:`storage_suffixes` is not empty, a sequence aligned with it. + """ + return self.get() + + def from_storage(self, value: Any) -> None: + """Restore the value from its serialized form. + + Args: + value: The serialized value, as produced by :meth:`to_storage`. + """ + self.set(value) + + def context(self, temp_value: Any) -> Generator[None, None, None]: + """Temporarily override the value without changing initialization state.""" + + @contextmanager + def _ctx(): + old_value = self._value + old_field_initialized = self._is_initialized + old_container_initialized = self._container.is_option_initialized(self.name) + snapshot_context = getattr( + self._container, "snapshot_option_context_state", None + ) + context_state = ( + snapshot_context(self.name) if snapshot_context is not None else None + ) + self.set(temp_value) + try: + yield + finally: + self._value = old_value + self._is_initialized = old_field_initialized + if old_container_initialized: + self._container.mark_option_initialized(self.name) + else: + self._container.unmark_option_initialized(self.name) + self._container.option_changed(self.name) + restore_context = getattr( + self._container, "restore_option_context_state", None + ) + if restore_context is not None: + restore_context(self.name, context_state) + + return _ctx() + + +class TypedOptionField(OptionField): + """Typed SigimaX option field with optional-default initialization.""" + + def __init__( + self, + container: OptionsContainer, + name: str, + default: Any, + expected_type: type, + description: str = "", + category: str = "", + storage_key: str = "", + runtime: bool = False, + ) -> None: + self.expected_type = expected_type + OptionField.__init__( + self, container, name, default, description, category, storage_key, runtime + ) + + def check(self, value: Any) -> None: + """Validate the configured value type.""" + if not isinstance(value, self.expected_type): + raise ValueError( + f"Expected {self.expected_type.__name__}, got {type(value).__name__}" + ) + + +class ImageIOOptionField(OptionField): + """Image I/O option field with optional-default initialization.""" + + def check(self, value: Any) -> None: + """Validate image I/O format and description pairs.""" + if not isinstance(value, (tuple, list)) or not all( + isinstance(item, (tuple, list)) and len(item) == 2 for item in value + ): + raise ValueError( + "Expected a tuple of tuples with two elements each " + "(format, description)" + ) + for item in value: + if not isinstance(item[0], str) or not isinstance(item[1], str): + raise ValueError( + "Each item must be a tuple of (format, description) as strings" + ) + + def set(self, value: Any) -> None: + """Set formats, mark initialization, and generate format classes.""" + OptionField.set(self, value) + # pylint: disable=cyclic-import,import-outside-toplevel + from sigima.io.image import formats + + formats.generate_imageio_format_classes(value) + + +class EnumOptionField(OptionField): + """Option field constrained to a fixed set of valid string values. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default: Default value (must be one of ``choices``). + choices: List of valid string values. + description: Description of the option. + """ + + def __init__( + self, + container: AppOptionsContainer, + name: str, + default: str, + choices: list[str], + description: str = "", + category: str = "", + storage_key: str = "", + runtime: bool = False, + ) -> None: + self.choices = choices + super().__init__( + container, name, default, description, category, storage_key, runtime + ) + + def check(self, value: Any) -> None: + """Check if value is one of the allowed choices. + + Args: + value: The value to check. + + Raises: + ValueError: If the value is not one of the allowed choices. + """ + if value not in self.choices: + raise ValueError( + f"Option '{self.name}': expected one of {self.choices}, got {value!r}" + ) + + +class TupleOptionField(OptionField): + """Option field for tuple values (e.g., window position, size). + + Handles JSON serialization where tuples become lists, automatically + converting back to tuples on load. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default: Default value (tuple or None). + description: Description of the option. + """ + + def check(self, value: Any) -> None: + """Check if value is a tuple, list (from JSON), or None. + + Args: + value: The value to check. + + Raises: + ValueError: If the value is not a valid type. + """ + if value is not None and not isinstance(value, (tuple, list)): + raise ValueError( + f"Option '{self.name}': expected tuple, list, or None, " + f"got {type(value).__name__}" + ) + + def set(self, value: Any) -> None: + """Set the value, converting lists to tuples. + + Args: + value: The new value to assign. + """ + if isinstance(value, list): + value = tuple(value) + super().set(value) + + +class FontOptionField(OptionField): + """Option field for font specifications. + + Stores fonts as a tuple of (family: str | list[str], size: int, bold: bool). + A list of families is a list of *candidate* names, resolved to the first one + available on the system by :meth:`get_font`. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default: Default value as (family, size, bold) tuple. + description: Description of the option. + """ + + storage_suffixes = ("family", "size", "bold") + + def check(self, value: Any) -> None: + """Check if value is a valid font tuple. + + Args: + value: The value to check. + + Raises: + ValueError: If the value is not a valid font specification. + """ + if value is not None and ( + not isinstance(value, (tuple, list)) + or len(value) != 3 + or not isinstance(value[0], (str, list, tuple)) + ): + raise ValueError( + f"Option '{self.name}': expected (family, size, bold) tuple, " + f"got {value!r}" + ) + + def set(self, value: Any) -> None: + """Set the value, converting lists to tuples. + + Args: + value: The new value to assign. + """ + if isinstance(value, list): + value = tuple(value) + super().set(value) + + def get_font(self) -> QG.QFont: + """Return the font as a ``QFont`` instance. + + Returns: + The configured font as a ``QFont``. + """ + # Import here to avoid requiring a Qt application when only manipulating + # configuration files. + from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel + + family, size, bold = self.get() + if isinstance(family, (list, tuple)): + family = configtools.get_family(family) + return QG.QFont(family, size, QG.QFont.Bold if bold else QG.QFont.Normal) + + +class ConfigPathOptionField(OptionField): + """Option field for a file stored in the configuration directory. + + The raw stored value is a bare file *basename*. :meth:`get` validates the + basename and returns the absolute path inside the configuration directory. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default: Default file basename (e.g. ``".MyApp_traceback.log"``). + description: Description of the option. + """ + + def check(self, value: Any) -> None: + """Check that the value is a string. + + Args: + value: The value to check. + + Raises: + ValueError: If the value is not a string. + """ + if not isinstance(value, str): + raise ValueError(f"Expected str, got {type(value).__name__}") + + def get(self, default: Any = NO_DEFAULT) -> str: + """Return the absolute path inside the configuration directory. + + Args: + default: Optional basename used when the option is not initialized. + Returns: + The absolute path of the file inside the configuration directory. + + Raises: + ValueError: If the stored value is not a bare basename. + """ + fname = super().get(default) + if osp.basename(fname) != fname: + raise ValueError(f"Invalid configuration file name {fname}") + return _conf_module.Configuration.get_path(osp.basename(fname)) + + def to_storage(self) -> str: + """Return the raw stored basename (bypassing path resolution).""" + return self._value + + def from_storage(self, value: str) -> None: + """Set the raw stored basename without validation or env sync. + + Args: + value: The raw basename to store. + """ + self._value = value + self.mark_initialized() + + +class WorkingDirOptionField(OptionField): + """Option field for a working directory. + + :meth:`set` validates the directory (falling back to its parent when a file + path is given) and raises when invalid. :meth:`get` returns an empty string + when the stored directory no longer exists. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default: Default directory path (empty string by default). + description: Description of the option. + """ + + def check(self, value: Any) -> None: + """Check that the value is a string. + + Args: + value: The value to check. + + Raises: + ValueError: If the value is not a string. + """ + if not isinstance(value, str): + raise ValueError(f"Expected str, got {type(value).__name__}") + + def get(self, default: Any = NO_DEFAULT) -> str: + """Return the working directory, or an empty string if it is missing. + + Args: + default: Optional path used when the option is not initialized. + Returns: + The stored directory if it exists, otherwise an empty string. + """ + path = super().get(default) + if osp.isdir(path): + return path + return "" + + def set(self, value: str) -> None: + """Set the working directory, validating that it exists. + + Args: + value: The directory (or a file whose parent is used) to store. + Raises: + FileNotFoundError: If neither the value nor its parent is a directory. + """ + if not osp.isdir(value): + value = osp.dirname(value) + if not osp.isdir(value): + raise FileNotFoundError(f"Invalid working directory name {value}") + super().set(value) + + def to_storage(self) -> str: + """Return the raw stored directory (even if it no longer exists).""" + return self._value + + def from_storage(self, value: str) -> None: + """Set the raw stored directory without validation or env sync. + + Args: + value: The raw directory path to store. + """ + self._value = value + self.mark_initialized() + + +class FormatStringOptionField(TypedOptionField): + """Option field for a ``strftime``-style format string. + + The value is kept in clean form in memory (e.g. ``%H:%M:%S``); + format-string-sensitive backends escape it through :attr:`storage_escape`. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default: Default format string. + description: Description of the option. + category: Category of the option. + """ + + storage_escape = True + + def __init__( + self, + container: AppOptionsContainer, + name: str, + default: str, + description: str = "", + category: str = "", + storage_key: str = "", + runtime: bool = False, + ) -> None: + super().__init__( + container, name, default, str, description, category, storage_key, runtime + ) + + +class DataSetOptionField(OptionField): + """Option field holding a :class:`guidata.dataset.DataSet` instance. + + The default value is provided through a *default instance*, which may be set + lazily via :meth:`set_default_instance` (useful when the default depends on + PlotPy configuration that is not yet available at construction time). + + JSON (de)serialization helpers (:meth:`to_json`, :meth:`from_json`) are used + by the storage backend. Percent-escaping required by ConfigParser is handled + by the backend through :attr:`storage_escape`, not here. + + Args: + container: Options container instance to which this option belongs. + name: Name of the option. + default_instance: Default :class:`~guidata.dataset.DataSet` instance + (may be ``None`` and set later via :meth:`set_default_instance`). + description: Description of the option. + category: Category of the option. + """ + + storage_escape = True + + def __init__( + self, + container: AppOptionsContainer, + name: str, + default_instance: gds.DataSet | None = None, + description: str = "", + category: str = "", + storage_key: str = "", + runtime: bool = False, + ) -> None: + self.default_instance = default_instance + self._serialized_value: str | None = None + # The actively-set value starts as None; get() falls back to the default + # instance until an explicit value is assigned. + super().__init__( + container, + name, + None, + description, + category, + storage_key, + runtime, + ) + + def check(self, value: Any) -> None: + """Check that the value is a DataSet or None. + + Args: + value: The value to check. + + Raises: + ValueError: If the value is neither a DataSet nor None. + """ + if value is not None and not isinstance(value, gds.DataSet): + raise ValueError( + f"Option '{self.name}': expected a DataSet instance or None, " + f"got {type(value).__name__}" + ) + + def set_default_instance(self, default_instance: gds.DataSet) -> None: + """Set the default instance (for lazy initialization). + + Args: + default_instance: The default DataSet instance to use. + """ + self.default_instance = default_instance + + def get(self, default: Any = NO_DEFAULT) -> gds.DataSet | None: + """Return the current DataSet instance, or the default instance. + + Args: + default: Optional DataSet used when the option is not initialized. + Returns: + The actively-set DataSet if any, otherwise the default instance. + """ + value = super().get(default) + if self._serialized_value is not None: + try: + value = gds.json_to_dataset(self._serialized_value) + except Exception: # pylint: disable=broad-except + value = ( + default + if default is not NO_DEFAULT and default is not None + else self.default_instance + ) + self._value = None + self._is_initialized = False + self._container.unmark_option_initialized(self.name) + else: + self._value = value + self._serialized_value = None + return value if value is not None else self.default_instance + + def get_raw(self) -> gds.DataSet | None: + """Return the raw actively-set DataSet (``None`` if never set).""" + return self._value + + def set(self, value: gds.DataSet | None) -> None: + """Set a DataSet instance and discard any pending serialized value.""" + self._serialized_value = None + super().set(value) + + def to_storage(self) -> str | None: + """Return the actively-set DataSet as a JSON string (``None`` if unset).""" + return self.to_json() + + def from_storage(self, value: str | None) -> None: + """Restore the DataSet from a JSON string (``None`` clears the value). + + The JSON is resolved immediately, so that an unusable value (e.g. a + DataSet class that no longer exists) is reported back to the caller by + :meth:`to_storage` returning ``None``. + + Args: + value: The JSON string to deserialize, or ``None``. + """ + if value is None: + self._value = None + self._serialized_value = None + self.mark_initialized() + else: + self.from_json(value) + self.get() + + def to_json(self) -> str | None: + """Serialize the actively-set DataSet to a JSON string. + + Returns: + The JSON string of the actively-set DataSet, or ``None`` when no + value has been explicitly set (so the default instance applies). + """ + if self._serialized_value is not None: + return self._serialized_value + if self._value is None: + return None + return gds.dataset_to_json(self._value) + + def from_json(self, json_str: str) -> None: + """Deserialize a DataSet from a JSON string and store it. + + Args: + json_str: The JSON string to deserialize. + """ + self._value = None + self._serialized_value = json_str + self.mark_initialized() + + +# --------------------------------------------------------------------------- +# Base container with JSON file persistence +# --------------------------------------------------------------------------- + + +class AppOptionsContainer(OptionsContainer): + """Base options container for SigimaX-based GUI applications. + + Extends Sigima's :class:`OptionsContainer` with optional JSON file + persistence for GUI applications that need to save user preferences + across sessions. + + Derived applications should subclass this (or :class:`SigimaXOptions`) + to add their own options as OptionField attributes in ``__init__``. + + Class attributes: + APP_NAME: Application name used for default config directory. + This name can be overridden by derived applications to customize the config + path. + """ + + APP_NAME = "SigimaX" + + def __init__(self) -> None: # pylint: disable=super-init-not-called + # Intentionally NOT calling super().__init__() because + # OptionsContainer.__init__ creates Sigima-specific options. + # We start fresh with our own option fields. + self._initialized_options: set[str] = set() + + def is_option_initialized(self, name: str) -> bool: + """Return whether an option was explicitly set or externally loaded.""" + return name in self._initialized_options + + def mark_option_initialized(self, name: str) -> None: + """Mark an option as explicitly initialized.""" + self._initialized_options.add(name) + + def unmark_option_initialized(self, name: str) -> None: + """Mark an option as not explicitly initialized.""" + self._initialized_options.discard(name) + + def option_changed(self, name: str) -> None: + """Handle an option value change. + + Derived applications may override this hook to persist option values. + + Args: + name: Name of the changed option. + """ + + def generate_rst_doc(self) -> str: + """Generate reStructuredText documentation for all options. + + Returns: + A string containing the reStructuredText documentation. + """ + doc = """.. list-table:: + :header-rows: 1 + :align: left + + * - Name + - Default Value + - Description +""" + for name in vars(self): + opt = getattr(self, name) + if isinstance(opt, OptionField): + description_lines = opt.description.strip().split("\n") + description = "\n".join( + [description_lines[0]] + + [ + " " + line.strip() if line.strip() else "" + for line in description_lines[1:] + ] + ) + value = repr(opt.get()) + if len(value) > 200: + value = value[:197] + "..." + doc += f" * - ``{name}``\n" + doc += f" - ``{value}``\n" + doc += f" - {description}\n" + return doc + + # -- Dictionary serialization -- + + def to_dict(self) -> dict[str, Any]: + """Return all option values as a JSON-compatible dictionary. + + Returns: + A dictionary with option names as keys and their serialized values. + """ + return { + name: getattr(self, name).to_storage() + for name in vars(self) + if isinstance(getattr(self, name), OptionField) + } + + def from_dict(self, values: dict[str, Any]) -> None: + """Set option values from a JSON-compatible dictionary. + + Unknown keys are silently ignored, making this safe for loading + options from a newer or older version of the application. + + Args: + values: A dictionary with option names as keys and their new values. + """ + for name, value in values.items(): + if hasattr(self, name): + opt = getattr(self, name) + if isinstance(opt, OptionField): + try: + opt.from_storage(value) + except (ValueError, TypeError) as exc: + print( + f"[sigimax] Warning: invalid value for " + f"option '{name}': {exc}" + ) + + # -- JSON file persistence -- + + def _get_default_config_path(self) -> Path: + """Return the default path for the JSON configuration file. + + Uses the same config directory as the INI-based system for + backward compatibility. + + Returns: + Path to the default JSON configuration file. + """ + # Use guidata's config directory resolution + try: + config_dir = Path(_conf_module.Configuration.get_path("")) + except Exception: # pylint: disable=broad-except + # Fallback to user home directory + config_dir = Path.home() / f".{self.APP_NAME}" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / "options.json" + + def save(self, path: str | Path | None = None) -> None: + """Save current options to a JSON file. + + Args: + path: Path to save to. If None, uses the default config path. + """ + filepath = Path(path) if path else self._get_default_config_path() + filepath.parent.mkdir(parents=True, exist_ok=True) + with open(filepath, "w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + def load(self, path: str | Path | None = None) -> None: + """Load options from a JSON file. + + Missing keys are left at their current (default) values. + Unknown keys are silently ignored. + + Args: + path: Path to load from. If None, uses the default config path. + """ + filepath = Path(path) if path else self._get_default_config_path() + if not filepath.exists(): + return + try: + with open(filepath, encoding="utf-8") as f: + values = json.load(f) + self.from_dict(values) + except Exception as exc: # pylint: disable=broad-except + print(f"[sigimax] Warning: failed to load options from {filepath}: {exc}") + + # -- Introspection -- + + def describe_all(self) -> None: + """Print the name, value, and description of all options.""" + for name in vars(self): + opt = getattr(self, name) + if isinstance(opt, OptionField): + print(f"{name} = {opt.get()} # {opt.description}") + + def list_options(self) -> list[str]: + """Return the sorted list of all option names. + + Returns: + Sorted list of option attribute names. + """ + return sorted( + name for name in vars(self) if isinstance(getattr(self, name), OptionField) + ) + + +# --------------------------------------------------------------------------- +# PlotPy theme color constants +# --------------------------------------------------------------------------- + +#: ROI line color (default blue) +ROI_LINE_COLOR = "#5555ff" +#: ROI selected line color +ROI_SEL_LINE_COLOR = "#9393ff" +#: Marker line color (default dark red) +MARKER_LINE_COLOR = "#A11818" +#: Marker text color +MARKER_TEXT_COLOR = "#440909" + + +class SigimaXOptions(AppOptionsContainer): + """Generic options for SigimaX-based GUI applications. + + Contains all options that are useful for any derived application building + on SigimaX (window management, console, I/O, visualization defaults, + processing behavior). + + At the end of initialization, PlotPy's INI-based configuration is + initialized with default styles for plots, results, and ROI shapes. + Subclasses can override :meth:`get_plotpy_defaults` to customize these. + + Derived applications should subclass this to add app-specific options: + + .. code-block:: python + + class MyAppOptions(SigimaXOptions): + APP_NAME = "MyApp" + CONF_VERSION = "1.0.0" + + def __init__(self): + super().__init__() + self.my_option = TypedOptionField( + self, "my_option", default=True, + expected_type=bool, + description="My app-specific option", + ) + + options = MyAppOptions() + """ + + #: Configuration version string (override in subclass if needed) + CONF_VERSION = "1.0.0" + + def __init__(self) -> None: + super().__init__() + + # =================================================================== + # Derivated application info — Name, version, description, URLs + # =================================================================== + + self.app_name = TypedOptionField( + self, + "app_name", + default=self.APP_NAME, + expected_type=str, + description="Application name.", + ) + self.app_version = TypedOptionField( + self, + "app_version", + default="0.1.0", + expected_type=str, + description="Application version.", + ) + self.app_logo_path = TypedOptionField( + self, + "app_logo_path", + default="", + expected_type=str, + description="Path to the application logo.", + ) + self.app_desc = TypedOptionField( + self, + "app_desc", + default="", + expected_type=str, + description="Application description.", + ) + self.app_local_doc_path = TypedOptionField( + self, + "app_local_doc_path", + default="", + expected_type=str, + description="Path pattern to a local PDF documentation file. " + "Use '{lang}' as a placeholder for the locale prefix " + "(e.g. 'data/doc/MyApp_{lang}.pdf'). " + "If empty, no local PDF menu item is shown.", + ) + self.app_docurl = TypedOptionField( + self, + "app_docurl", + default="", + expected_type=str, + description="URL to the application documentation.", + ) + self.app_homeurl = TypedOptionField( + self, + "app_homeurl", + default="", + expected_type=str, + description="URL to the application homepage.", + ) + self.app_supporturl = TypedOptionField( + self, + "app_supporturl", + default="", + expected_type=str, + description="URL to the application support/contact page.", + ) + self.app_developer = TypedOptionField( + self, + "app_developer", + default="", + expected_type=str, + description="Developer or organization name shown in the About dialog.", + ) + self.app_copyright = TypedOptionField( + self, + "app_copyright", + default="", + expected_type=str, + description="Copyright notice shown in the About dialog " + "(e.g. '2023 My Organization').", + ) + self.splash_image_path = TypedOptionField( + self, + "splash_image_path", + default="", + expected_type=str, + description="Path to the splash screen image (PNG, SVG, etc.). " + "If empty, no splash screen is shown.", + ) + self.splash_show_progress = TypedOptionField( + self, + "splash_show_progress", + default=True, + expected_type=bool, + description="If True, display progress messages on the splash screen " + "during application startup.", + ) + + # =================================================================== + # Main options — Application-level settings + # =================================================================== + + self.color_mode = EnumOptionField( + self, + "color_mode", + category="main", + default="auto", + choices=["auto", "dark", "light"], + description="Application color mode (auto, dark, or light).", + ) + self.datetime_format = TypedOptionField( + self, + "datetime_format", + default="%d/%m/%Y - %H:%M:%S", + expected_type=str, + description="Application datetime format.", + ) + + # =================================================================== + # Log and Console state + # =================================================================== + + self.traceback_log_path = ConfigPathOptionField( + self, + "traceback_log_path", + category="main", + default=f".{self.APP_NAME}_traceback.log", + description="Traceback log file basename (inside the config dir).", + ) + self.traceback_log_available = TypedOptionField( + self, + "traceback_log_available", + category="main", + default=False, + expected_type=bool, + description="Whether a traceback log file is currently available.", + ) + self.faulthandler_enabled = TypedOptionField( + self, + "faulthandler_enabled", + category="main", + default=True, + expected_type=bool, + description="If True, enable Python faulthandler for crash reporting.", + ) + self.faulthandler_log_path = ConfigPathOptionField( + self, + "faulthandler_log_path", + category="main", + default=f".{self.APP_NAME}_faulthandler.log", + description="Faulthandler log file basename (inside the config dir).", + ) + self.faulthandler_log_available = TypedOptionField( + self, + "faulthandler_log_available", + category="main", + default=False, + expected_type=bool, + description="Whether a faulthandler log file is currently available.", + ) + + # =================================================================== + # Other Application options + # =================================================================== + + self.available_memory_threshold = TypedOptionField( + self, + "available_memory_threshold", + category="main", + default=500, + expected_type=int, + description=( + "Available memory threshold in MB. A warning is shown " + "when available memory drops below this value." + ), + ) + self.ignore_warnings = TypedOptionField( + self, + "ignore_warnings", + category="proc", + default=False, + expected_type=bool, + description=("If True, suppress Python warnings during computations."), + ) + + # =================================================================== + # Window state — Persisted UI geometry + # =================================================================== + + self.window_maximized = TypedOptionField( + self, + "window_maximized", + category="main", + default=False, + expected_type=bool, + description="Whether the main window was maximized on last close.", + ) + self.window_position = TupleOptionField( + self, + "window_position", + category="main", + default=None, + description="Main window position as (x, y) tuple, or None.", + ) + self.window_size = TupleOptionField( + self, + "window_size", + category="main", + default=None, + description="Main window size as (width, height) tuple, or None.", + ) + self.window_state = TypedOptionField( + self, + "window_state", + category="main", + default="", + expected_type=str, + description=( + "Main window state (hex-encoded QByteArray) for restoring " + "dock widget positions and toolbar layout." + ), + ) + self.base_dir = WorkingDirOptionField( + self, + "base_dir", + category="main", + default="", + description="Base working directory for file dialogs.", + ) + + # =================================================================== + # Console options — Embedded console settings + # =================================================================== + + self.console_enabled = TypedOptionField( + self, + "console_enabled", + category="console", + default=True, + expected_type=bool, + description="If True, show the embedded Python console.", + ) + self.show_console_on_error = TypedOptionField( + self, + "show_console_on_error", + category="console", + default=False, + expected_type=bool, + description=( + "If True, automatically show the console when an error occurs." + ), + ) + self.console_max_line_count = TypedOptionField( + self, + "console_max_line_count", + category="console", + default=5000, + expected_type=int, + storage_key="max_line_count", + description="Maximum number of lines to keep in the console output.", + ) + self.external_editor_path = TypedOptionField( + self, + "external_editor_path", + category="console", + default="code", + expected_type=str, + description=( + "Path to the external editor executable (e.g., 'code' for VS Code)." + ), + ) + self.external_editor_args = TypedOptionField( + self, + "external_editor_args", + category="console", + default="-g {path}:{line_number}", + expected_type=str, + description=( + "Command-line arguments template for the external editor. " + "Supports {path} and {line_number} placeholders." + ), + ) + + # =================================================================== + # I/O options — File import/export settings + # =================================================================== + + self.h5_clear_workspace = TypedOptionField( + self, + "h5_clear_workspace", + category="io", + default=True, + expected_type=bool, + description=( + "If True, clear the workspace before loading an HDF5 file " + "(avoids UUID conflicts)." + ), + ) + self.h5_clear_workspace_ask = TypedOptionField( + self, + "h5_clear_workspace_ask", + category="io", + default=True, + expected_type=bool, + description=( + "If True, ask user for confirmation before clearing workspace " + "when loading an HDF5 file." + ), + ) + self.h5_fullpath_in_title = TypedOptionField( + self, + "h5_fullpath_in_title", + category="io", + default=False, + expected_type=bool, + description=( + "If True, use full HDF5 dataset path in signal/image title. " + "If False, use only the dataset name." + ), + ) + self.h5_fname_in_title = TypedOptionField( + self, + "h5_fname_in_title", + category="io", + default=True, + expected_type=bool, + description=("If True, include the HDF5 file name in signal/image title."), + ) + self.imageio_formats = ImageIOOptionField( + self, + "imageio_formats", + category="io", + default=(), + description="Supported ImageIO file formats.", + ) + + # =================================================================== + # View options — Plot and visualization defaults + # =================================================================== + + self.plot_toolbar_position = EnumOptionField( + self, + "plot_toolbar_position", + category="view", + default="left", + choices=["top", "bottom", "left", "right"], + description="Position of the plot toolbar.", + ) + self.plot_dock_location = EnumOptionField( + self, + "plot_dock_location", + category="view", + default="right", + choices=["top", "bottom", "left", "right"], + description="Default dock area for plot widgets " + "(top, bottom, left, or right).", + ) + self.watermark_image_path = TypedOptionField( + self, + "watermark_image_path", + category="view", + default="", + expected_type=str, + description="Path to the watermark image displayed on empty plots. " + "If empty, no watermark is shown.", + ) + + self.sig_format = TypedOptionField( + self, + "sig_format", + category="view", + default="", + expected_type=str, + description="Format string for signal shape legends.", + ) + self.ima_format = TypedOptionField( + self, + "ima_format", + category="view", + default="", + expected_type=str, + description="Format string for image shape legends.", + ) + self.show_label = TypedOptionField( + self, + "show_label", + category="view", + default=False, + expected_type=bool, + description="If True, show labels on plot items.", + ) + self.sig_linewidth = TypedOptionField( + self, + "sig_linewidth", + category="view", + default=1.0, + expected_type=float, + description="Default line width for signal curves.", + ) + self.sig_linewidth_perfs_threshold = TypedOptionField( + self, + "sig_linewidth_perfs_threshold", + category="view", + default=1000, + expected_type=int, + description=( + "Number of curves above which line width is forced to 1 " + "for performance reasons." + ), + ) + self.sig_autodownsampling = TypedOptionField( + self, + "sig_autodownsampling", + category="view", + default=True, + expected_type=bool, + description=( + "If True, automatically downsample signals with many points " + "for faster rendering." + ), + ) + self.sig_autodownsampling_maxpoints = TypedOptionField( + self, + "sig_autodownsampling_maxpoints", + category="view", + default=100000, + expected_type=int, + description="Maximum number of points before auto-downsampling kicks in.", + ) + self.sig_autoscale_margin_percent = TypedOptionField( + self, + "sig_autoscale_margin_percent", + category="view", + default=2.0, + expected_type=float, + description="Margin percentage for signal plot autoscale.", + ) + self.ima_autoscale_margin_percent = TypedOptionField( + self, + "ima_autoscale_margin_percent", + category="view", + default=1.0, + expected_type=float, + description="Margin percentage for image plot autoscale.", + ) + self.ima_eliminate_outliers = TypedOptionField( + self, + "ima_eliminate_outliers", + category="view", + default=0.1, + expected_type=float, + description=( + "Percentage of outliers to eliminate from image LUT range " + "at item creation (0.0 to disable)." + ), + ) + + # --- Signal visualization defaults (persisted in object metadata) --- + + self.sig_def_shade = TypedOptionField( + self, + "sig_def_shade", + category="view", + default=0.0, + expected_type=float, + description="Default shade value for signal curves (0.0 = no shade).", + ) + self.sig_def_curvestyle = TypedOptionField( + self, + "sig_def_curvestyle", + category="view", + default="Lines", + expected_type=str, + description=( + "Default curve style for signals " + "(e.g., 'Lines', 'Sticks', 'Steps', 'Dots')." + ), + ) + self.sig_def_baseline = TypedOptionField( + self, + "sig_def_baseline", + category="view", + default=0.0, + expected_type=float, + description="Default baseline value for signal curves.", + ) + + # --- Image visualization defaults (persisted in object metadata) --- + + self.ima_def_colormap = TypedOptionField( + self, + "ima_def_colormap", + category="view", + default="viridis", + expected_type=str, + description="Default colormap for images (e.g., 'viridis', 'gray').", + ) + self.ima_def_invert_colormap = TypedOptionField( + self, + "ima_def_invert_colormap", + category="view", + default=False, + expected_type=bool, + description="If True, invert the default colormap.", + ) + self.ima_def_interpolation = TypedOptionField( + self, + "ima_def_interpolation", + category="view", + default=5, + expected_type=int, + description="Default interpolation mode for images (integer index).", + ) + self.ima_def_alpha = TypedOptionField( + self, + "ima_def_alpha", + category="view", + default=1.0, + expected_type=float, + description="Default alpha (opacity) for images (0.0 to 1.0).", + ) + self.ima_def_alpha_function = TypedOptionField( + self, + "ima_def_alpha_function", + category="view", + default=0, + expected_type=int, + description=( + "Default alpha function for images (LUTAlpha enum value). " + "0 = NONE (uniform alpha)." + ), + ) + self.ima_def_keep_lut_range = TypedOptionField( + self, + "ima_def_keep_lut_range", + category="view", + default=False, + expected_type=bool, + description=( + "If True, keep the LUT range when switching between images " + "instead of auto-scaling." + ), + ) + + # =================================================================== + # Processing options — Computation behavior + # =================================================================== + + self.operation_mode = EnumOptionField( + self, + "operation_mode", + category="proc", + default="single", + choices=["single", "pairwise"], + description=( + "Operation mode for multi-selection computations. " + "'single': one operand shared, 'pairwise': paired operations." + ), + ) + self.extract_roi_singleobj = TypedOptionField( + self, + "extract_roi_singleobj", + category="proc", + default=False, + expected_type=bool, + description=( + "If True, extract all ROIs into a single object. " + "If False, create one object per ROI." + ), + ) + self.keep_results = TypedOptionField( + self, + "keep_results", + category="proc", + default=False, + expected_type=bool, + description=( + "If True, keep analysis results after processing. " + "Warning: results may become invalid after transformations." + ), + ) + self.show_result_dialog = TypedOptionField( + self, + "show_result_dialog", + category="proc", + default=True, + expected_type=bool, + description=( + "If True, systematically show a result dialog after " + "analysis computations." + ), + ) + self.use_signal_bounds = TypedOptionField( + self, + "use_signal_bounds", + category="proc", + default=False, + expected_type=bool, + description=( + "If True, use xmin and xmax bounds from the current signal " + "when creating a new signal." + ), + ) + self.use_image_dims = TypedOptionField( + self, + "use_image_dims", + category="proc", + default=True, + expected_type=bool, + description=( + "If True, use dimensions from the current image when " + "creating a new image." + ), + ) + self.fft_shift_enabled = TypedOptionField( + self, + "fft_shift_enabled", + category="proc", + default=True, + expected_type=bool, + description=( + "If True, apply FFT shift to center the zero-frequency " + "component. Synced with sigima.config.options." + ), + ) + self.auto_normalize_kernel = TypedOptionField( + self, + "auto_normalize_kernel", + category="proc", + default=False, + expected_type=bool, + description=( + "If True, automatically normalize convolution kernels to " + "sum to 1.0 before convolution. " + "Synced with sigima.config.options." + ), + ) + self.xarray_compat_behavior = EnumOptionField( + self, + "xarray_compat_behavior", + category="proc", + default="ask", + choices=["ask", "interpolate"], + description=( + "Behavior when X-arrays are incompatible in multi-signal " + "operations. 'ask': prompt user, 'interpolate': auto-interpolate." + ), + ) + + # =================================================================== + # Initialize PlotPy INI-based configuration + # =================================================================== + self.initialize_plotpy() + # =================================================================== + # Sync with sigima.config.options for shared settings (e.g., FFT shift, kernel + # normalization) + # =================================================================== + self.sync_with_sigima() + + # =================================================================== + # Capture default values for reset_to_defaults() + # (Sigima's OptionField does not expose a .default attribute) + # # TODO: [P3] Refactor OptionField to store the default value explicitly, + # so we can simplify this logic in the future. + # =================================================================== + self._defaults = self.to_dict() + + def reset_to_defaults(self) -> None: + """Reset all options to their default values.""" + self._initialized_options.clear() + self.from_dict(self._defaults) + for name in self._defaults: + field = getattr(self, name) + field._is_initialized = False # pylint: disable=protected-access + self._initialized_options.clear() + + # -- Option categories (INI sections and settings-UI grouping) -- + + def get_option_categories(self) -> list[tuple[str, str]]: + """Return ordered option categories as ``(id, label)`` pairs. + + The ``id`` doubles as the INI section name for derived applications and + as the persistence/grouping key; the ``label`` is a human-readable, + translatable title suitable for a settings dialog tab. + + Derived applications extend this by concatenating their own categories + to the result of ``super().get_option_categories()``. + + Returns: + Ordered list of ``(category_id, label)`` pairs. + """ + return [ + ("main", _("General")), + ("console", _("Console")), + ("io", _("I/O")), + ("proc", _("Processing")), + ("view", _("Visualization")), + ] + + def get_field_category(self, name: str) -> str: + """Return the category id of an option field (empty if uncategorized). + + Args: + name: The option field name. + + Returns: + The category id, or an empty string if the field is uncategorized + or unknown. + """ + field = getattr(self, name, None) + return getattr(field, "category", "") if field is not None else "" + + def fields_by_category(self) -> dict[str, list[str]]: + """Return option field names grouped by category id. + + Categories are keyed in the order returned by + :meth:`get_option_categories`; uncategorized fields are omitted. + + Returns: + Mapping ``category_id -> [option_field_name, ...]``. + """ + result: dict[str, list[str]] = { + cid: [] for cid, _label in self.get_option_categories() + } + for name in vars(self): + field = getattr(self, name) + if isinstance(field, OptionField): + category = getattr(field, "category", "") + if category: + result.setdefault(category, []).append(name) + return result + + # -- PlotPy INI-based configuration integration -- + + def get_plotpy_defaults(self) -> dict[str, dict[str, Any]]: + """Return default PlotPy configuration values. + + Override this method in subclasses to customize the PlotPy styles + for plots, result annotations, ROI shapes, and labels. + + The returned dict has top-level keys corresponding to PlotPy + configuration sections (``"plot"``, ``"results"``, ``"roi"``). + + Returns: + Nested dictionary of PlotPy default settings. + """ + # Read at call time: `plotpy.config.set_plotpy_color_mode` rebinds these + # module globals when the color theme changes. + main_fg = plotpy_config.MAIN_FG_COLOR + main_bg = plotpy_config.MAIN_BG_COLOR + return { + "plot": { + # Overriding default plot settings from PlotPy + "title/font/size": 11, + "title/font/bold": False, + "selected_curve_symbol/marker": "Ellipse", + "selected_curve_symbol/edgecolor": "#a0a0a4", + "selected_curve_symbol/facecolor": main_fg, + "selected_curve_symbol/alpha": 0.3, + "selected_curve_symbol/size": 5, + "marker/curve/text/textcolor": "black", + # Cross marker style (shown when pressing Alt key on plot) + "marker/cross/symbol/marker": "Cross", + "marker/cross/symbol/edgecolor": main_fg, + "marker/cross/symbol/facecolor": "#ff0000", + "marker/cross/symbol/alpha": 1.0, + "marker/cross/symbol/size": 8, + "marker/cross/text/font/family": "default", + "marker/cross/text/font/size": 8, + "marker/cross/text/font/bold": False, + "marker/cross/text/font/italic": False, + "marker/cross/text/textcolor": "#000000", + "marker/cross/text/background_color": "#ffffff", + "marker/cross/text/background_alpha": 0.7, + "marker/cross/line/style": "DashLine", + "marker/cross/line/color": MARKER_LINE_COLOR, + "marker/cross/line/width": 1.0, + "marker/cross/markerstyle": "Cross", + "marker/cross/spacing": 7, + # Cursor line and symbol style + "marker/cursor/line/style": "SolidLine", + "marker/cursor/line/color": MARKER_LINE_COLOR, + "marker/cursor/line/width": 1.0, + "marker/cursor/symbol/marker": "NoSymbol", + "marker/cursor/symbol/size": 11, + "marker/cursor/symbol/edgecolor": main_bg, + "marker/cursor/symbol/facecolor": "#ff9393", + "marker/cursor/symbol/alpha": 1.0, + "marker/cursor/sel_line/style": "SolidLine", + "marker/cursor/sel_line/color": MARKER_LINE_COLOR, + "marker/cursor/sel_line/width": 2.0, + "marker/cursor/sel_symbol/marker": "NoSymbol", + "marker/cursor/sel_symbol/size": 11, + "marker/cursor/sel_symbol/edgecolor": main_bg, + "marker/cursor/sel_symbol/facecolor": MARKER_LINE_COLOR, + "marker/cursor/sel_symbol/alpha": 0.8, + "marker/cursor/text/font/size": 9, + "marker/cursor/text/font/family": "default", + "marker/cursor/text/font/bold": False, + "marker/cursor/text/font/italic": False, + "marker/cursor/text/textcolor": MARKER_TEXT_COLOR, + "marker/cursor/text/background_color": "#ffffff", + "marker/cursor/text/background_alpha": 0.7, + "marker/cursor/sel_text/font/size": 9, + "marker/cursor/sel_text/font/family": "default", + "marker/cursor/sel_text/font/bold": False, + "marker/cursor/sel_text/font/italic": False, + "marker/cursor/sel_text/textcolor": MARKER_TEXT_COLOR, + "marker/cursor/sel_text/background_color": "#ffffff", + "marker/cursor/sel_text/background_alpha": 0.7, + # Default annotation text style for segments + "shape/segment/line/style": "SolidLine", + "shape/segment/line/color": "#00ff55", + "shape/segment/line/width": 1.0, + "shape/segment/sel_line/style": "SolidLine", + "shape/segment/sel_line/color": "#00ff55", + "shape/segment/sel_line/width": 2.0, + "shape/segment/fill/style": "NoBrush", + "shape/segment/sel_fill/style": "NoBrush", + "shape/segment/symbol/marker": "XCross", + "shape/segment/symbol/size": 9, + "shape/segment/symbol/edgecolor": "#00ff55", + "shape/segment/symbol/facecolor": "#00ff55", + "shape/segment/symbol/alpha": 1.0, + "shape/segment/sel_symbol/marker": "XCross", + "shape/segment/sel_symbol/size": 12, + "shape/segment/sel_symbol/edgecolor": "#00ff55", + "shape/segment/sel_symbol/facecolor": "#00ff55", + "shape/segment/sel_symbol/alpha": 0.7, + # Default style for drag shapes (global annotations style) + "shape/drag/line/style": "SolidLine", + "shape/drag/line/color": "#00ff55", + "shape/drag/line/width": 1.0, + "shape/drag/fill/style": "SolidPattern", + "shape/drag/fill/color": main_bg, + "shape/drag/fill/alpha": 0.1, + "shape/drag/symbol/marker": "Rect", + "shape/drag/symbol/size": 3, + "shape/drag/symbol/edgecolor": "#00ff55", + "shape/drag/symbol/facecolor": "#00ff55", + "shape/drag/symbol/alpha": 1.0, + "shape/drag/sel_line/style": "SolidLine", + "shape/drag/sel_line/color": "#00ff55", + "shape/drag/sel_line/width": 2.0, + "shape/drag/sel_fill/style": "SolidPattern", + "shape/drag/sel_fill/color": main_bg, + "shape/drag/sel_fill/alpha": 0.1, + "shape/drag/sel_symbol/marker": "Rect", + "shape/drag/sel_symbol/size": 7, + "shape/drag/sel_symbol/edgecolor": "#00ff55", + "shape/drag/sel_symbol/facecolor": "#00ff00", + "shape/drag/sel_symbol/alpha": 0.7, + }, + "results": { + # Annotated shape style for result shapes: + # Signals: + "s/annotation/line/style": "SolidLine", + "s/annotation/line/color": "#00aa00", + "s/annotation/line/width": 2, + "s/annotation/fill/style": "NoBrush", + "s/annotation/fill/color": main_bg, + "s/annotation/fill/alpha": 0.1, + "s/annotation/symbol/marker": "XCross", + "s/annotation/symbol/size": 7, + "s/annotation/symbol/edgecolor": "#00aa00", + "s/annotation/symbol/facecolor": "#00aa00", + "s/annotation/symbol/alpha": 1.0, + "s/annotation/sel_line/style": "DashLine", + "s/annotation/sel_line/color": "#00ff00", + "s/annotation/sel_line/width": 1, + "s/annotation/sel_fill/style": "SolidPattern", + "s/annotation/sel_fill/color": main_bg, + "s/annotation/sel_fill/alpha": 0.1, + "s/annotation/sel_symbol/marker": "Rect", + "s/annotation/sel_symbol/size": 9, + "s/annotation/sel_symbol/edgecolor": "#00aa00", + "s/annotation/sel_symbol/facecolor": "#00ff00", + "s/annotation/sel_symbol/alpha": 0.7, + # Images: + "i/annotation/line/style": "SolidLine", + "i/annotation/line/color": "#ffff00", + "i/annotation/line/width": 2, + "i/annotation/fill/style": "SolidPattern", + "i/annotation/fill/color": main_bg, + "i/annotation/fill/alpha": 0.1, + "i/annotation/symbol/marker": "Rect", + "i/annotation/symbol/size": 3, + "i/annotation/symbol/edgecolor": "#ffff00", + "i/annotation/symbol/facecolor": "#ffff00", + "i/annotation/symbol/alpha": 1.0, + "i/annotation/sel_line/style": "SolidLine", + "i/annotation/sel_line/color": "#00ff00", + "i/annotation/sel_line/width": 2, + "i/annotation/sel_fill/style": "SolidPattern", + "i/annotation/sel_fill/color": main_bg, + "i/annotation/sel_fill/alpha": 0.1, + "i/annotation/sel_symbol/marker": "Rect", + "i/annotation/sel_symbol/size": 9, + "i/annotation/sel_symbol/edgecolor": "#00aa00", + "i/annotation/sel_symbol/facecolor": "#00ff00", + "i/annotation/sel_symbol/alpha": 0.7, + # Marker styles for results: + # Signals: + "s/marker/cursor/line/style": "DashLine", + "s/marker/cursor/line/color": MARKER_LINE_COLOR, + "s/marker/cursor/line/width": 1.0, + "s/marker/cursor/symbol/marker": "Ellipse", + "s/marker/cursor/symbol/size": 11, + "s/marker/cursor/symbol/edgecolor": main_bg, + "s/marker/cursor/symbol/facecolor": MARKER_LINE_COLOR, + "s/marker/cursor/symbol/alpha": 0.7, + "s/marker/cursor/sel_line/style": "DashLine", + "s/marker/cursor/sel_line/color": MARKER_LINE_COLOR, + "s/marker/cursor/sel_line/width": 2.0, + "s/marker/cursor/sel_symbol/marker": "Ellipse", + "s/marker/cursor/sel_symbol/size": 11, + "s/marker/cursor/sel_symbol/edgecolor": MARKER_LINE_COLOR, + "s/marker/cursor/sel_symbol/facecolor": MARKER_LINE_COLOR, + "s/marker/cursor/sel_symbol/alpha": 0.7, + "s/marker/cursor/text/font/size": 9, + "s/marker/cursor/text/font/family": "default", + "s/marker/cursor/text/font/bold": False, + "s/marker/cursor/text/font/italic": False, + "s/marker/cursor/text/textcolor": MARKER_TEXT_COLOR, + "s/marker/cursor/text/background_color": "#ffffff", + "s/marker/cursor/text/background_alpha": 0.7, + "s/marker/cursor/sel_text/font/size": 9, + "s/marker/cursor/sel_text/font/family": "default", + "s/marker/cursor/sel_text/font/bold": False, + "s/marker/cursor/sel_text/font/italic": False, + "s/marker/cursor/sel_text/textcolor": MARKER_TEXT_COLOR, + "s/marker/cursor/sel_text/background_color": "#ffffff", + "s/marker/cursor/sel_text/background_alpha": 0.7, + "s/marker/cursor/markerstyle": "Cross", + # Images: + "i/marker/cursor/line/style": "DashLine", + "i/marker/cursor/line/color": MARKER_LINE_COLOR, + "i/marker/cursor/line/width": 1.0, + "i/marker/cursor/symbol/marker": "Diamond", + "i/marker/cursor/symbol/size": 11, + "i/marker/cursor/symbol/edgecolor": MARKER_LINE_COLOR, + "i/marker/cursor/symbol/facecolor": MARKER_LINE_COLOR, + "i/marker/cursor/symbol/alpha": 0.7, + "i/marker/cursor/sel_line/style": "DashLine", + "i/marker/cursor/sel_line/color": MARKER_LINE_COLOR, + "i/marker/cursor/sel_line/width": 2.0, + "i/marker/cursor/sel_symbol/marker": "Diamond", + "i/marker/cursor/sel_symbol/size": 11, + "i/marker/cursor/sel_symbol/edgecolor": MARKER_LINE_COLOR, + "i/marker/cursor/sel_symbol/facecolor": MARKER_LINE_COLOR, + "i/marker/cursor/sel_symbol/alpha": 0.7, + "i/marker/cursor/text/font/size": 9, + "i/marker/cursor/text/font/family": "default", + "i/marker/cursor/text/font/bold": False, + "i/marker/cursor/text/font/italic": False, + "i/marker/cursor/text/textcolor": MARKER_TEXT_COLOR, + "i/marker/cursor/text/background_color": "#ffffff", + "i/marker/cursor/text/background_alpha": 0.7, + "i/marker/cursor/sel_text/font/size": 9, + "i/marker/cursor/sel_text/font/family": "default", + "i/marker/cursor/sel_text/font/bold": False, + "i/marker/cursor/sel_text/font/italic": False, + "i/marker/cursor/sel_text/textcolor": MARKER_TEXT_COLOR, + "i/marker/cursor/sel_text/background_color": "#ffffff", + "i/marker/cursor/sel_text/background_alpha": 0.7, + "i/marker/cursor/markerstyle": "Cross", + # Style for labels: + "label/symbol/marker": "NoSymbol", + "label/symbol/size": 0, + "label/symbol/edgecolor": main_bg, + "label/symbol/facecolor": main_bg, + "label/border/style": "SolidLine", + "label/border/color": "#cbcbcb", + "label/border/width": 1, + "label/font/size": 8, + "label/font/family/nt": [ + "Cascadia Code", + "Consolas", + "Courier New", + ], + "label/font/family/posix": "Bitstream Vera Sans Mono", + "label/font/family/mac": "Monaco", + "label/font/bold": False, + "label/font/italic": False, + "label/color": main_fg, + "label/bgcolor": main_bg, + "label/bgalpha": 0.8, + "label/anchor": "TL", + "label/xc": 10, + "label/yc": 10, + "label/abspos": True, + "label/absg": "TL", + "label/xg": 0.0, + "label/yg": 0.0, + }, + "roi": { + # Signals — Editable ROI (ROI editor): + "s/editable/fill": "#ffff00", + "s/editable/shade": 0.10, + "s/editable/line/style": "SolidLine", + "s/editable/line/color": "#ffff00", + "s/editable/line/width": 1, + "s/editable/fill/style": "SolidPattern", + "s/editable/fill/color": main_bg, + "s/editable/fill/alpha": 0.1, + "s/editable/symbol/marker": "Rect", + "s/editable/symbol/size": 3, + "s/editable/symbol/edgecolor": "#ffff00", + "s/editable/symbol/facecolor": "#ffff00", + "s/editable/symbol/alpha": 1.0, + "s/editable/sel_line/style": "SolidLine", + "s/editable/sel_line/color": "#00ff00", + "s/editable/sel_line/width": 1, + "s/editable/sel_fill/style": "SolidPattern", + "s/editable/sel_fill/color": main_bg, + "s/editable/sel_fill/alpha": 0.1, + "s/editable/sel_symbol/marker": "Rect", + "s/editable/sel_symbol/size": 9, + "s/editable/sel_symbol/edgecolor": "#00aa00", + "s/editable/sel_symbol/facecolor": "#00ff00", + "s/editable/sel_symbol/alpha": 0.7, + # Signals — Readonly ROI (plot): + "s/readonly/line/style": "SolidLine", + "s/readonly/line/color": ROI_LINE_COLOR, + "s/readonly/line/width": 1, + "s/readonly/sel_line/style": "SolidLine", + "s/readonly/sel_line/color": ROI_SEL_LINE_COLOR, + "s/readonly/sel_line/width": 2, + "s/readonly/fill": ROI_LINE_COLOR, + "s/readonly/shade": 0.10, + "s/readonly/symbol/marker": "Ellipse", + "s/readonly/symbol/size": 7, + "s/readonly/symbol/edgecolor": main_bg, + "s/readonly/symbol/facecolor": ROI_LINE_COLOR, + "s/readonly/symbol/alpha": 1.0, + "s/readonly/sel_symbol/marker": "Ellipse", + "s/readonly/sel_symbol/size": 9, + "s/readonly/sel_symbol/edgecolor": main_bg, + "s/readonly/sel_symbol/facecolor": ROI_SEL_LINE_COLOR, + "s/readonly/sel_symbol/alpha": 0.9, + "s/readonly/multi/color": "#806060", + # Images — Editable ROI (ROI editor): + "i/editable/line/style": "SolidLine", + "i/editable/line/color": "#ffff00", + "i/editable/line/width": 1, + "i/editable/fill/style": "SolidPattern", + "i/editable/fill/color": main_bg, + "i/editable/fill/alpha": 0.1, + "i/editable/symbol/marker": "Rect", + "i/editable/symbol/size": 3, + "i/editable/symbol/edgecolor": "#ffff00", + "i/editable/symbol/facecolor": "#ffff00", + "i/editable/symbol/alpha": 1.0, + "i/editable/sel_line/style": "SolidLine", + "i/editable/sel_line/color": "#00ff00", + "i/editable/sel_line/width": 1, + "i/editable/sel_fill/style": "SolidPattern", + "i/editable/sel_fill/color": main_bg, + "i/editable/sel_fill/alpha": 0.1, + "i/editable/sel_symbol/marker": "Rect", + "i/editable/sel_symbol/size": 9, + "i/editable/sel_symbol/edgecolor": "#00aa00", + "i/editable/sel_symbol/facecolor": "#00ff00", + "i/editable/sel_symbol/alpha": 0.7, + # Images — Readonly ROI (plot): + "i/readonly/line/style": "DotLine", + "i/readonly/line/color": ROI_LINE_COLOR, + "i/readonly/line/width": 1, + "i/readonly/fill/style": "SolidPattern", + "i/readonly/fill/color": main_bg, + "i/readonly/fill/alpha": 0.1, + "i/readonly/symbol/marker": "NoSymbol", + "i/readonly/symbol/size": 5, + "i/readonly/symbol/edgecolor": ROI_LINE_COLOR, + "i/readonly/symbol/facecolor": ROI_LINE_COLOR, + "i/readonly/symbol/alpha": 0.6, + "i/readonly/sel_line/style": "DotLine", + "i/readonly/sel_line/color": "#0000ff", + "i/readonly/sel_line/width": 1, + "i/readonly/sel_fill/style": "SolidPattern", + "i/readonly/sel_fill/color": main_bg, + "i/readonly/sel_fill/alpha": 0.1, + "i/readonly/sel_symbol/marker": "Rect", + "i/readonly/sel_symbol/size": 8, + "i/readonly/sel_symbol/edgecolor": "#0000aa", + "i/readonly/sel_symbol/facecolor": "#0000ff", + "i/readonly/sel_symbol/alpha": 0.7, + }, + } + + def apply_plotpy_defaults(self) -> None: + """Apply the default PlotPy styles returned by :meth:`get_plotpy_defaults`. + + This method is called automatically at the end of ``__init__``. It must + also be called again after each color theme change, since the defaults + depend on PlotPy's current foreground/background colors. + """ + PLOTPY_CONF.update_defaults(self.get_plotpy_defaults()) + + def set_plotpy_application( + self, config_app_name: str = "", load: bool = False + ) -> None: + """Set the application name used for PlotPy's INI file. + + Args: + config_app_name: Application name for the PlotPy INI file + (e.g., ``"MyApp_v1"``). If empty, PlotPy uses its own default. + load: If True, load existing user settings from the INI file. + """ + PLOTPY_CONF.set_application( + osp.join(config_app_name, "plotpy") if config_app_name else "plotpy", + self.CONF_VERSION, + load=load, + ) + + def initialize_plotpy(self, config_app_name: str = "", load: bool = False) -> None: + """Initialize PlotPy's INI-based configuration. + + Convenience wrapper around :meth:`apply_plotpy_defaults` and + :meth:`set_plotpy_application`. + + Args: + config_app_name: Application name for the PlotPy INI file + (e.g., ``"MyApp_v1"``). If empty, PlotPy uses its own default. + load: If True, load existing user settings from the INI file. + """ + self.apply_plotpy_defaults() + self.set_plotpy_application(config_app_name, load=load) + + def sync_with_sigima(self) -> None: + """Synchronize relevant options with Sigima's options container. + + Call this after loading or modifying options that have Sigima counterparts + (``fft_shift_enabled``, ``auto_normalize_kernel``, ``imageio_formats``). + """ + sigima_options.fft_shift_enabled.set(self.fft_shift_enabled.get()) + sigima_options.auto_normalize_kernel.set(self.auto_normalize_kernel.get()) + sigima_options.imageio_formats.set(self.imageio_formats.get()) + + def get_sigima_defaults(self, category: str) -> dict: + """Get default Sigima visualization settings as a dictionary. + + Collects all options named ``{category}_def_*`` and returns them + as a dictionary with the ``{category}_def_`` prefix stripped. + + Args: + category: 'sig' for signal defaults, 'ima' for image defaults. + + Returns: + Dictionary of default visualization settings. + + Example: + >>> options.get_sigima_defaults("ima") + {'colormap': 'viridis', 'alpha': 1.0, ...} + """ + assert category in ("ima", "sig"), f"Expected 'ima' or 'sig', got {category!r}" + prefix = f"{category}_def_" + result = {} + for name in vars(self): + if name.startswith(prefix): + opt = getattr(self, name) + if isinstance(opt, OptionField): + value = opt.get() + if value is not None: + result[name[len(prefix) :]] = value + return result + + def set_sigima_defaults(self, category: str, defaults: dict) -> None: + """Set default Sigima visualization settings from a dictionary. + + Args: + category: 'sig' for signal defaults, 'ima' for image defaults. + defaults: Dictionary of setting names (without prefix) to values. + + Example: + >>> options.set_sigima_defaults("ima", {"colormap": "gray"}) + """ + assert category in ("ima", "sig"), f"Expected 'ima' or 'sig', got {category!r}" + prefix = f"{category}_def_" + for key, value in defaults.items(): + attr_name = f"{prefix}{key}" + if hasattr(self, attr_name): + opt = getattr(self, attr_name) + if isinstance(opt, OptionField): + opt.set(value) + + +#: Global instance of SigimaX options. +#: Derived applications should create their own instance of their subclass. +CONF = SigimaXOptions() + +#: Names of every option defined by the SigimaX base configuration. A derived +#: application may add options and override values, but it may **not** remove a +#: base option (doing so would break SigimaX modules that read it at runtime). +_BASE_OPTION_NAMES: frozenset[str] = frozenset( + name for name in vars(CONF) if isinstance(getattr(CONF, name), OptionField) +) + +#: The currently active options container. Defaults to the SigimaX base +#: configuration; a derived application installs its own container via +#: :func:`set_conf`. All SigimaX modules read the active container through +#: :func:`get_conf` (never by binding ``CONF`` directly), so that a derived +#: application's configuration is honoured transparently regardless of import +#: order. +_active_conf: AppOptionsContainer = CONF + + +def get_conf() -> AppOptionsContainer: + """Return the currently active options container. + + SigimaX modules (and derived-application code) should call this at runtime + to read configuration options, e.g. ``get_conf().color_mode.get()``. + + Returns: + The active options container (the SigimaX base by default, or the + container installed by a derived application via :func:`set_conf`). + """ + return _active_conf + + +def set_conf(container: AppOptionsContainer) -> None: + """Install a derived application's options container as the active one. + + The container must define **every** SigimaX base option (base options + cannot be removed) so that SigimaX modules never fail a runtime lookup. It + may freely add new options and override default values. + + Args: + container: The options container to activate (typically a subclass of + :class:`SigimaXOptions`). + + Raises: + ValueError: If the container is missing one or more base SigimaX options. + """ + container_names = { + name + for name in vars(container) + if isinstance(getattr(container, name), OptionField) + } + missing = _BASE_OPTION_NAMES - container_names + if missing: + raise ValueError( + "Cannot install options container: the following base SigimaX " + f"options are missing (base options cannot be removed): {sorted(missing)}" + ) + global _active_conf # pylint: disable=global-statement + _active_conf = container + + +def reset_conf() -> None: + """Restore the SigimaX base configuration as the active container. + + Mainly useful for tests that install a derived container and need to revert. + """ + global _active_conf # pylint: disable=global-statement + _active_conf = CONF diff --git a/sigimax/data/icons/analysis/show_results.svg b/sigimax/data/icons/analysis/show_results.svg new file mode 100644 index 0000000..57a79c2 --- /dev/null +++ b/sigimax/data/icons/analysis/show_results.svg @@ -0,0 +1,83 @@ + + diff --git a/sigimax/data/icons/apply.svg b/sigimax/data/icons/apply.svg new file mode 100644 index 0000000..2733cbe --- /dev/null +++ b/sigimax/data/icons/apply.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/sigimax/data/icons/check_all.svg b/sigimax/data/icons/check_all.svg new file mode 100644 index 0000000..c854a7f --- /dev/null +++ b/sigimax/data/icons/check_all.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sigimax/data/icons/collapse.svg b/sigimax/data/icons/collapse.svg new file mode 100644 index 0000000..db1ff6d --- /dev/null +++ b/sigimax/data/icons/collapse.svg @@ -0,0 +1,44 @@ + + + + + + + collapse + + diff --git a/sigimax/data/icons/collapse_selection.svg b/sigimax/data/icons/collapse_selection.svg new file mode 100644 index 0000000..26047db --- /dev/null +++ b/sigimax/data/icons/collapse_selection.svg @@ -0,0 +1,63 @@ + + + + + + + collapse + + + + + + collapse + + + + diff --git a/sigimax/data/icons/expand.svg b/sigimax/data/icons/expand.svg new file mode 100644 index 0000000..32c907d --- /dev/null +++ b/sigimax/data/icons/expand.svg @@ -0,0 +1,44 @@ + + + + + + + expand + + diff --git a/sigimax/data/icons/expand_selection.svg b/sigimax/data/icons/expand_selection.svg new file mode 100644 index 0000000..294ed43 --- /dev/null +++ b/sigimax/data/icons/expand_selection.svg @@ -0,0 +1,63 @@ + + + + + + + expand + + + + + + expand + + + + diff --git a/sigimax/data/icons/h5/h5attrs.svg b/sigimax/data/icons/h5/h5attrs.svg new file mode 100644 index 0000000..ab895e7 --- /dev/null +++ b/sigimax/data/icons/h5/h5attrs.svg @@ -0,0 +1,75 @@ + + diff --git a/sigimax/data/icons/h5/h5browser.svg b/sigimax/data/icons/h5/h5browser.svg new file mode 100644 index 0000000..fd2adfd --- /dev/null +++ b/sigimax/data/icons/h5/h5browser.svg @@ -0,0 +1,133 @@ + + diff --git a/sigimax/data/icons/h5/h5file.svg b/sigimax/data/icons/h5/h5file.svg new file mode 100644 index 0000000..2272e2e --- /dev/null +++ b/sigimax/data/icons/h5/h5file.svg @@ -0,0 +1,69 @@ + + diff --git a/sigimax/data/icons/h5/h5group.svg b/sigimax/data/icons/h5/h5group.svg new file mode 100644 index 0000000..536f124 --- /dev/null +++ b/sigimax/data/icons/h5/h5group.svg @@ -0,0 +1,49 @@ + + diff --git a/sigimax/data/icons/help_pdf.svg b/sigimax/data/icons/help_pdf.svg new file mode 100644 index 0000000..e95c3e1 --- /dev/null +++ b/sigimax/data/icons/help_pdf.svg @@ -0,0 +1,46 @@ + + diff --git a/sigimax/data/icons/io/fileopen_h5.svg b/sigimax/data/icons/io/fileopen_h5.svg new file mode 100644 index 0000000..0a6acda --- /dev/null +++ b/sigimax/data/icons/io/fileopen_h5.svg @@ -0,0 +1,84 @@ + + diff --git a/sigimax/data/icons/io/filesave_h5.svg b/sigimax/data/icons/io/filesave_h5.svg new file mode 100644 index 0000000..598f808 --- /dev/null +++ b/sigimax/data/icons/io/filesave_h5.svg @@ -0,0 +1,97 @@ + + diff --git a/sigimax/data/icons/libre-gui-about.svg b/sigimax/data/icons/libre-gui-about.svg new file mode 100644 index 0000000..c9b622c --- /dev/null +++ b/sigimax/data/icons/libre-gui-about.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sigimax/data/icons/libre-gui-close.svg b/sigimax/data/icons/libre-gui-close.svg new file mode 100644 index 0000000..11392b4 --- /dev/null +++ b/sigimax/data/icons/libre-gui-close.svg @@ -0,0 +1,40 @@ + + diff --git a/sigimax/data/icons/libre-gui-globe.svg b/sigimax/data/icons/libre-gui-globe.svg new file mode 100644 index 0000000..21f5d6c --- /dev/null +++ b/sigimax/data/icons/libre-gui-globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sigimax/data/icons/libre-gui-help.svg b/sigimax/data/icons/libre-gui-help.svg new file mode 100644 index 0000000..3bdced9 --- /dev/null +++ b/sigimax/data/icons/libre-gui-help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sigimax/data/icons/libre-gui-menu.svg b/sigimax/data/icons/libre-gui-menu.svg new file mode 100644 index 0000000..8ed1acb --- /dev/null +++ b/sigimax/data/icons/libre-gui-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sigimax/data/icons/logs.svg b/sigimax/data/icons/logs.svg new file mode 100644 index 0000000..0bd146e --- /dev/null +++ b/sigimax/data/icons/logs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sigimax/data/icons/menu.svg b/sigimax/data/icons/menu.svg new file mode 100644 index 0000000..9ecf7c3 --- /dev/null +++ b/sigimax/data/icons/menu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sigimax/data/icons/restore.svg b/sigimax/data/icons/restore.svg new file mode 100644 index 0000000..412a35a --- /dev/null +++ b/sigimax/data/icons/restore.svg @@ -0,0 +1,40 @@ + + diff --git a/sigimax/data/icons/to_signal.svg b/sigimax/data/icons/to_signal.svg new file mode 100644 index 0000000..3535c76 --- /dev/null +++ b/sigimax/data/icons/to_signal.svg @@ -0,0 +1,124 @@ + + diff --git a/sigimax/data/icons/uncheck_all.svg b/sigimax/data/icons/uncheck_all.svg new file mode 100644 index 0000000..dd10d47 --- /dev/null +++ b/sigimax/data/icons/uncheck_all.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + diff --git a/sigimax/data/icons/view/refresh-auto.svg b/sigimax/data/icons/view/refresh-auto.svg new file mode 100644 index 0000000..37dbd0d --- /dev/null +++ b/sigimax/data/icons/view/refresh-auto.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + diff --git a/sigimax/data/tests/empty.h5 b/sigimax/data/tests/empty.h5 new file mode 100644 index 0000000..571fd46 Binary files /dev/null and b/sigimax/data/tests/empty.h5 differ diff --git a/sigimax/data/tests/reordering_test.h5 b/sigimax/data/tests/reordering_test.h5 new file mode 100644 index 0000000..0859b0e Binary files /dev/null and b/sigimax/data/tests/reordering_test.h5 differ diff --git a/sigimax/env.py b/sigimax/env.py new file mode 100644 index 0000000..48809a7 --- /dev/null +++ b/sigimax/env.py @@ -0,0 +1,544 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX environment utilities (:mod:`sigimax.env`) +================================================== + +.. autoclass:: VerbosityLevels + :members: +.. autoclass:: SGMXExecEnv + :members: +""" + +from __future__ import annotations + +__all__ = [ + "SGMXExecEnv", + "VerbosityLevels", + "execenv", +] + +import argparse +import enum +import os +import platform +import pprint +import sys +import traceback +from contextlib import contextmanager +from typing import Any, Generator + +from guidata.env import ExecEnv as GuiDataExecEnv + +from sigimax._metadata import __version__ + +# We could import DEBUG from sigimax.config, but is it really worth it? +DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") + + +class VerbosityLevels(enum.Enum): + """Print verbosity levels (for testing purpose)""" + + QUIET = "quiet" + NORMAL = "normal" + DEBUG = "debug" + + +# TODO: [P3] Rewrite this class so that options are automatically associated with +# environment variables and command line arguments. +# +# Use the unit test "datalab\tests\backbone\execenv_unit.py" to check that +# everything still works as expected. +# +# This could be done using objects deriving from something like this (and +# implementing integer, boolean, string, choices): +# +# class EnvVar: +# """Descriptor for handling attributes +# associated with environment variables""" +# +# def __init__( +# self, name: str, default: Optional[str] = None, +# argname: Optional[str] = None +# ): +# """ +# Initialize the EnvVar descriptor. +# +# Args: +# name: The name of the associated environment variable. +# default: The default value for the attribute. +# argname: The name of the command-line argument (optional). +# """ +# self.name = name +# self.default = default +# self.argname = argname +# +# def __get__(self, instance: Optional[object], +# owner: type) -> Optional[str]: +# """ +# Get the value of the attribute. +# +# Args: +# instance: The instance of the class. +# owner: The class that owns the attribute. +# +# Returns: +# The value of the attribute. +# """ +# if instance is None: +# return self +# value = os.environ.get(self.name) +# if value is None: +# return self.default +# return self._envvar_to_value(value) +# +# def __set__(self, instance: object, value: Optional[str]) -> None: +# """ +# Set the value of the attribute. +# +# Args: +# instance: The instance of the class. +# value: The value to be set. +# """ +# if value is not None: +# os.environ[self.name] = self._value_to_envvar(value) +# elif self.name in os.environ: +# os.environ.pop(self.name) +# +# def __delete__(self, instance: object) -> None: +# """ +# Delete the attribute and remove the associated environment variable. +# +# Args: +# instance: The instance of the class. +# """ +# os.environ.pop(self.name, None) +# +# def add_argument(self, parser: argparse.ArgumentParser) -> None: +# """ +# Add the command-line argument to the given ArgumentParser. +# +# Args: +# parser: The ArgumentParser to add the argument to. +# """ +# parser.add_argument( +# f"--{self.argname}", +# default=self.default, +# help=f"{self.argname} (environment variable: {self.name})", +# ) +# +# def _envvar_to_value(self, value: str) -> str: +# """ +# Convert the environment variable value to the attribute value. +# This method must be implemented by subclasses. +# """ +# return value +# +# def _value_to_envvar(self, value: str) -> str: +# """ +# Convert the attribute value to the environment variable value. +# This method must be implemented by subclasses. +# """ +# return value +# + + +class SGMXExecEnv: + """Object representing SigimaX test environment""" + + UNATTENDED_ARG = "unattended" + ACCEPT_DIALOGS_ARG = "accept_dialogs" + VERBOSE_ARG = "verbose" + SCREENSHOT_ARG = "screenshot" + SCREENSHOT_PATH_ARG = "screenshot_path" + DELAY_ARG = "delay" + DO_NOT_QUIT_ENV = "SIGIMAX_DO_NOT_QUIT" + UNATTENDED_ENV = GuiDataExecEnv.UNATTENDED_ENV + ACCEPT_DIALOGS_ENV = GuiDataExecEnv.ACCEPT_DIALOGS_ENV + VERBOSE_ENV = GuiDataExecEnv.VERBOSE_ENV + SCREENSHOT_ENV = GuiDataExecEnv.SCREENSHOT_ENV + SCREENSHOT_PATH_ENV = GuiDataExecEnv.SCREENSHOT_PATH_ENV + DELAY_ENV = GuiDataExecEnv.DELAY_ENV + CATCHER_TEST_ENV = "SIGIMAX_CATCHER_TEST" + + def __init__(self, parse_args: bool = True): + self.h5files = None + self.h5browser_file = None + self.demo_mode = False + # Check if "pytest" is in the command line arguments: + if parse_args and "pytest" not in sys.argv[0]: + # Do not parse command line arguments when running tests with pytest + # (otherwise, pytest arguments are parsed as SigimaX arguments) + self.parse_args() + if self.unattended: # Do not run this code in production + # Check that calling `to_dict` do not raise any exception + self.to_dict() + + def iterate_over_attrs_envvars(self) -> Generator[tuple[str, str], None, None]: + """Iterate over SigimaX environment variables + + Yields: + A tuple (attribute name, environment variable name) + """ + for name in dir(self): + if name.endswith("_ENV"): + envvar: str = getattr(self, name) + attrname = "_".join(name.split("_")[:-1]).lower() + yield attrname, envvar + + def to_dict(self): + """Return a dictionary representation of the object""" + # The list of properties match the list of environment variable attribute names, + # modulo the "_ENV" suffix: + props = [attrname for attrname, _envvar in self.iterate_over_attrs_envvars()] + + # Check that all properties are defined in the class and that they are + # really properties: + for prop in props: + assert hasattr(self, prop), ( + f"Property {prop} is not defined in class {self.__class__.__name__}" + ) + assert isinstance(getattr(self.__class__, prop), property), ( + f"Attribute {prop} is not a property in class {self.__class__.__name__}" + ) + + # Add complementary properties: + props += [ + "h5files", + "h5browser_file", + "demo_mode", + ] + + # Return a dictionary with the properties as keys and their values as values: + return {p: getattr(self, p) for p in props} + + def __str__(self): + """Return a string representation of the object""" + return pprint.pformat(self.to_dict()) + + def enable_demo_mode(self, delay: int): + """Enable demo mode + + Args: + delay: Delay (ms) before quitting application in unattended mode + """ + self.demo_mode = True + self.unattended = True + self.delay = delay + + def disable_demo_mode(self): + """Disable demo mode""" + self.demo_mode = False + self.unattended = False + self.delay = 0 + + @staticmethod + def __get_mode(env): + """Get mode value""" + env_val = os.environ.get(env) + if env_val is None: + return False + return env_val.lower() in ("1", "true", "yes", "on", "enable", "enabled") + + @staticmethod + def __set_mode(env, value): + """Set mode value""" + if env in os.environ: + os.environ.pop(env) + if value: + os.environ[env] = "1" + + @property + def do_not_quit(self): + """Keep QApplication running (and widgets opened) after test execution, + even in unattended mode (e.g. useful for testing the remote client API: + we need to run SigimaX app in unattended mode [to avoid any user interaction + during the test] but we also need to keep the QApplication running to + be able to send commands to the remote client API). + """ + return self.__get_mode(self.DO_NOT_QUIT_ENV) + + @do_not_quit.setter + def do_not_quit(self, value): + """Set do_not_quit value""" + self.__set_mode(self.DO_NOT_QUIT_ENV, value) + + @property + def unattended(self): + """Get unattended value""" + return self.__get_mode(self.UNATTENDED_ENV) + + @unattended.setter + def unattended(self, value): + """Set unattended value""" + self.__set_mode(self.UNATTENDED_ENV, value) + + @property + def accept_dialogs(self): + """Whether to accept dialogs in unattended mode""" + return self.__get_mode(self.ACCEPT_DIALOGS_ENV) + + @accept_dialogs.setter + def accept_dialogs(self, value): + """Set whether to accept dialogs in unattended mode""" + self.__set_mode(self.ACCEPT_DIALOGS_ENV, value) + + @property + def catcher_test(self): + """Get catcher_test value""" + return self.__get_mode(self.CATCHER_TEST_ENV) + + @catcher_test.setter + def catcher_test(self, value): + """Set catcher_test value""" + self.__set_mode(self.CATCHER_TEST_ENV, value) + + @property + def screenshot(self): + """Get screenshot value""" + return self.__get_mode(self.SCREENSHOT_ENV) + + @screenshot.setter + def screenshot(self, value): + """Set screenshot value""" + self.__set_mode(self.SCREENSHOT_ENV, value) + + @property + def screenshot_path(self): + """Get screenshot path""" + return os.environ.get(self.SCREENSHOT_PATH_ENV, "") + + @screenshot_path.setter + def screenshot_path(self, value): + """Set screenshot path""" + if value: + os.environ[self.SCREENSHOT_PATH_ENV] = str(value) + elif self.SCREENSHOT_PATH_ENV in os.environ: + os.environ.pop(self.SCREENSHOT_PATH_ENV) + + @property + def verbose(self): + """Get verbosity level""" + env_val = os.environ.get(self.VERBOSE_ENV) + if env_val in (None, ""): + return VerbosityLevels.NORMAL.value + return env_val.lower() + + @verbose.setter + def verbose(self, value): + """Set verbosity level""" + os.environ[self.VERBOSE_ENV] = value + + @property + def delay(self): + """Delay (ms) before quitting application in unattended mode""" + try: + return int(os.environ.get(self.DELAY_ENV)) + except (TypeError, ValueError): + return 0 + + @delay.setter + def delay(self, value: int): + """Set delay (ms) before quitting application in unattended mode""" + os.environ[self.DELAY_ENV] = str(value) + + def parse_args(self): + """Parse command line arguments""" + # WARNING + # Do not add an option '-c' to avoid any conflict with macro command + # execution mecanism used with SigimaX standalone version (see start.pyw) + + parser = argparse.ArgumentParser(description="Run SigimaX application") + parser.add_argument( + "h5", + nargs="?", + type=str, + help="HDF5 file names (separated by ';'), " + "optionally with dataset name (separated by ',')", + ) + parser.add_argument( + "-b", + "--h5browser", + required=False, + type=str, + metavar="path", + help="path to open with HDF5 browser", + ) + parser.add_argument( + "-v", "--version", action="store_true", help="show SigimaX version" + ) + parser.add_argument( + "--reset", action="store_true", help="reset SigimaX configuration" + ) + parser.add_argument( + "--" + self.UNATTENDED_ARG, + action="store_true", + help="non-interactive mode", + default=None, + ) + parser.add_argument( + "--" + self.ACCEPT_DIALOGS_ARG, + action="store_true", + help="accept dialogs in unattended mode", + default=None, + ) + parser.add_argument( + "--" + self.SCREENSHOT_ARG, + action="store_true", + help="automatic screenshots", + default=None, + ) + parser.add_argument( + "--" + self.SCREENSHOT_PATH_ARG, + type=str, + help="path to save screenshots", + default=None, + ) + parser.add_argument( + "--" + self.DELAY_ARG, + type=int, + default=None, + help="delay (ms) before quitting application in unattended mode", + ) + parser.add_argument( + "--" + self.VERBOSE_ARG, + choices=[lvl.value for lvl in VerbosityLevels], + required=False, + default=None, + help="verbosity level: for debugging/testing purpose", + ) + args, _unknown = parser.parse_known_args() + if args.h5: + self.h5files = args.h5.split(";") + if args.h5browser: + self.h5browser_file = args.h5browser + if args.version: + # Local import: `sigimax.config` pulls in the PlotPy/Qt stack, which + # this module must not require just to parse command line arguments. + # pylint: disable=import-outside-toplevel + from sigimax.config import CONF as Conf + + print( + ( + f"{Conf.app_name} {Conf.app_version}," + f" derivated from SigimaX {__version__} on {platform.system()}" + ) + ) + sys.exit() + if args.reset: + # Local import: see the `--version` branch above. + # pylint: disable=import-outside-toplevel + from sigimax.config import CONF as Conf + + print("Resetting SigimaX configuration...", end=" ") + try: + Conf.reset_to_defaults() + except Exception: # pylint: disable=broad-except + print("Failed.") + traceback.print_exc() + finally: + print("Done.") + sys.exit() + self.set_env_from_args(args) + + def set_env_from_args(self, args): + """Set appropriate environment variables""" + for argname in ( + self.UNATTENDED_ARG, + self.ACCEPT_DIALOGS_ARG, + self.SCREENSHOT_ARG, + self.SCREENSHOT_PATH_ARG, + self.VERBOSE_ARG, + self.DELAY_ARG, + ): + argvalue = getattr(args, argname) + if argvalue is not None: + setattr(self, argname, argvalue) + + def log(self, source: Any, *objects: Any) -> None: + """Log text on screen + + Args: + source: object from which the log is issued + *objects: objects to log + """ + if DEBUG or self.verbose == VerbosityLevels.DEBUG.value: + print(str(source) + ":", *objects) + # TODO: [P4] Eventually, log in a file (optionally) + + def print(self, *objects, sep=" ", end="\n", file=sys.stdout, flush=False): + """Print in file, depending on verbosity level""" + if self.verbose != VerbosityLevels.QUIET.value or DEBUG: + print(*objects, sep=sep, end=end, file=file, flush=flush) + + def pprint( + self, + obj, + stream=None, + indent=1, + width=80, + depth=None, + compact=False, + sort_dicts=True, + ): + """Pretty-print in stream, depending on verbosity level""" + if self.verbose != VerbosityLevels.QUIET.value or DEBUG: + pprint.pprint( + obj, + stream=stream, + indent=indent, + width=width, + depth=depth, + compact=compact, + sort_dicts=sort_dicts, + ) + + @contextmanager + def context( + self, + unattended=None, + accept_dialogs=None, + screenshot=None, + delay=None, + verbose=None, + xmlrpcport=None, + catcher_test=None, + ) -> Generator[None, None, None]: + """Return a context manager that sets some execenv properties at enter, + and restores them at exit. This is useful to run some code in a + controlled environment, for example to accept dialogs in unattended + mode, and restore the previous value at exit. + + Args: + unattended: whether to run in unattended mode + accept_dialogs: whether to accept dialogs in unattended mode + screenshot: whether to take screenshots + delay: delay (ms) before quitting application in unattended mode + verbose: verbosity level + xmlrpcport: XML-RPC port number + catcher_test: whether to run catcher test + + .. note:: + If a passed value is None, the corresponding property is not changed. + """ + old_values = self.to_dict() + new_values = { + "unattended": unattended, + "accept_dialogs": accept_dialogs, + "screenshot": screenshot, + "delay": delay, + "verbose": verbose, + "xmlrpcport": xmlrpcport, + "catcher_test": catcher_test, + } + for key, value in new_values.items(): + if value is not None: + setattr(self, key, value) + try: + yield + finally: + for key, value in old_values.items(): + setattr(self, key, value) + + +execenv = SGMXExecEnv(parse_args=False) diff --git a/sigimax/h5/__init__.py b/sigimax/h5/__init__.py new file mode 100644 index 0000000..8b6f422 --- /dev/null +++ b/sigimax/h5/__init__.py @@ -0,0 +1,36 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + + +""" +SigimaX HDF5 I/O module (:mod:`sigimax.h5`) +============================================ + +The importer walks an HDF5 file and turns each dataset into a +:class:`~sigimax.h5.common.BaseNode` subclass instance, selected by the +module-level :data:`~sigimax.h5.common.NODE_FACTORY` registry. Applications +extend the supported data model by subclassing +:class:`~sigimax.h5.common.BaseNode` (or :class:`~sigimax.h5.common.GroupNode`) +and registering it with ``NODE_FACTORY.register(MyNode)`` — see +:doc:`../user_guide/hdf5_workspace` for a worked example. + +.. autoclass:: sigimax.h5.common.H5Importer + :members: +.. autoclass:: sigimax.h5.common.NodeFactory + :members: +.. autoclass:: sigimax.h5.common.BaseNode + :members: +.. autoclass:: sigimax.h5.common.GroupNode + :members: +.. autoclass:: sigimax.h5.common.RootNode + :members: +""" + +__all__ = [ + "H5Importer", +] + +# pylint: disable=unused-import + +# Registering dynamic I/O features: +from sigimax.h5 import generic # noqa: F401 +from sigimax.h5.common import H5Importer # noqa: F401 diff --git a/sigimax/h5/common.py b/sigimax/h5/common.py new file mode 100644 index 0000000..1dd76e6 --- /dev/null +++ b/sigimax/h5/common.py @@ -0,0 +1,328 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX Common tools for exogenous HDF5 format support +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +import abc +import os.path as osp +from collections.abc import Callable + +import h5py +import numpy as np +from guidata.utils.misc import to_string +from sigima.io.common.converters import convert_array_to_valid_dtype +from sigima.objects import ImageObj, SignalObj + +from sigimax.config import get_conf + + +def data_to_xy(data: np.ndarray) -> list[np.ndarray]: + """Convert 2-D array into a list of 1-D array data (x, y, dx, dy). + This is useful for importing data and creating a SigimaX signal with it. + + Args: + data (numpy.ndarray): 2-D array of data + + Returns: + list[np.ndarray]: list of 1-D array data (x, y, dx, dy) + """ + if len(data.ravel()) == len(data): + return np.arange(len(data)), data.ravel(), None, None + rows, cols = data.shape + for colnb in (2, 3, 4): + if cols == colnb and rows > colnb: + data = data.T + break + if len(data) == 1: + data = data.T + if len(data) not in (2, 3, 4): + raise ValueError(f"Invalid data: len(data)={len(data)} (expected 2, 3 or 4)") + x, y = data[:2] + dx, dy = None, None + if len(data) == 3: + dy = data[2] + if len(data) == 4: + dx, dy = data[2:] + return x, y, dx, dy + + +class BaseNode(metaclass=abc.ABCMeta): + """Object representing a HDF5 node""" + + IS_ARRAY = False + + def __init__(self, h5file, dname): + self.h5file = h5file + self.dset = h5file[dname] + self.metadata = {} + self.__obj = None + self.children = [] + self.uint32_wng = False + + @property + def id(self): + """Return node id""" + return self.dset.name + + @property + def name(self): + """Return node name, constructed from dataset name""" + return to_string(self.dset.name).split("/")[-1] + + @property + def data(self): + """Data associated to node, if available""" + return None + + @property + def icon_name(self): + """Icon name associated to node""" + + @property + def shape_str(self): + """Return string representation of node shape, if any""" + return "" + + @property + def dtype_str(self): + """Return string representation of node data type, if any""" + return "" + + @property + def text(self): + """Return node textual representation""" + return "" + + @property + def description(self): + """Return node description""" + return "" + + @classmethod + def match(cls, dset): + """Return True if h5 dataset match node pattern""" + + def is_supported(self) -> bool: + """Return True if node is associated to supported data""" + return False + + def create_native_object(self): + """Create native object, if supported""" + return None + + def get_native_object(self): + """Return native object, if supported""" + if self.__obj is None: + obj = self.create_native_object() # pylint: disable=assignment-from-none + if obj is not None: + self.__process_metadata(obj) + self.__obj = obj + return self.__obj + + def collect_attributes(self): + """Collect attributes from node. + + HDF5 attributes are opportunistically copied to the object metadata. + Values that cannot be safely serialized are skipped: HDF5 object or + region *reference* attributes (e.g. ``DIMENSION_LIST`` / + ``REFERENCE_LIST`` arrays produced by ``h4toh5convert``) are exposed by + h5py as ``object``-dtype arrays of :class:`h5py.h5r.Reference`, which + raise ``TypeError: no default __reduce__`` when DataLab pickles the + object to run a computation in a worker process. + """ + for key, value in self.dset.attrs.items(): + if isinstance(value, bytes): + value = to_string(value) + if isinstance(value, np.ndarray): + # Keep only numeric, boolean and string arrays. ``object`` and + # ``void`` dtypes (how h5py exposes reference and compound + # attributes) are not picklable, hence skipped. + if value.dtype.kind in "biufcSU": + self.metadata[key] = value + elif isinstance(value, (str, float, int, bool)) or np.isscalar(value): + self.metadata[key] = value + + def __process_metadata(self, obj): + """Process metadata from dataset to obj""" + obj.reset_metadata_to_defaults() + obj.set_metadata_option("HDF5Path", self.h5file.filename) + obj.set_metadata_option("HDF5Dataset", self.id) + obj.metadata.update(self.metadata) + + @property + def object_title(self): + """Return signal/image object title""" + conf = get_conf() + if conf.h5_fullpath_in_title.get(): + title = self.id + else: + title = self.name + if conf.h5_fname_in_title.get(): + title += f" ({osp.basename(self.h5file.filename)})" + return title + + def set_signal_data(self, obj: SignalObj) -> None: + """Set signal data (handles various issues)""" + data = self.data + if data.dtype not in (float, np.complex128): + data = np.array(data, dtype=float) + data = convert_array_to_valid_dtype(data, SignalObj.VALID_DTYPES) + if len(data.shape) == 1: + obj.set_xydata(np.arange(data.size), data) + else: + x, y, dx, dy = data_to_xy(data) + obj.set_xydata(x, y, dx, dy) + + def set_image_data(self, obj: ImageObj) -> None: + """Set image data (handles various issues)""" + data = self.data + if data.dtype == np.uint32: + self.uint32_wng = data.max() > np.iinfo(np.int32).max + clipped_data = data.clip(0, np.iinfo(np.int32).max) + data = np.array(clipped_data, dtype=np.int32) + obj.data = convert_array_to_valid_dtype(data, ImageObj.VALID_DTYPES) + + +class H5Importer: + """SigimaX HDF5 importer class""" + + def __init__(self, filename): + self.h5file = h5py.File(filename) + self.__nodes = {} + self.root = RootNode(self.h5file) + self.__nodes[self.root.id] = self.root + self.root.collect_children(self.__nodes) + NODE_FACTORY.run_post_triggers(self) + + @property + def nodes(self): + """Return all nodes""" + return self.__nodes.values() + + def get(self, node_id: str): + """Return node associated to id""" + return self.__nodes[node_id] + + def get_relative(self, node: BaseNode, relpath: str, ancestor: int = 0): + """Return node using relative path to another node""" + path = "/" + ( + "/".join(node.id.split("/")[:-ancestor]) + "/" + relpath.strip("/") + ).strip("/") + return self.__nodes[path] + + def close(self): + """Close HDF5 file""" + self.__nodes = {} + self.h5file.close() + + +class NodeFactory: + """Factory for node classes""" + + def __init__(self): + self.__ignored_datasets = [] + self.__generic_classes = [] + self.__thirdparty_classes = [] + self.__post_triggers = {} + + def add_ignored_datasets(self, names): + """Add h5 dataset name to ignore list""" + self.__ignored_datasets.extend(names) + + def add_post_trigger(self, nodecls: BaseNode, callback: Callable): + """Add post trigger function, to be called at the end of the collect process. + Callbacks take only one argument: H5Importer instance.""" + triggers = self.__post_triggers.setdefault(nodecls, []) + triggers.append(callback) + + def register(self, cls, is_generic=False): + """Register node class. + Generic classes are processed after specific classes (as a fallback solution)""" + if is_generic: + self.__generic_classes.append(cls) + else: + self.__thirdparty_classes.append(cls) + + def get(self, dset): + """Return node class that matches h5 dataset""" + for name in to_string(dset.name).split("/"): + if name in self.__ignored_datasets: + return None + for cls in self.__thirdparty_classes + self.__generic_classes: + try: + if cls.match(dset): + return cls + except (UnicodeDecodeError, TypeError, ValueError, OSError) as exc: + # Skip classes that can't match this dataset due to various issues + print( + f"Warning: Class {cls.__name__} can't match dataset " + f"'{dset.name}': {exc}" + ) + continue + if isinstance(dset, h5py.Group): + return GroupNode + return None + + def run_post_triggers(self, importer: H5Importer): + """Run post-collect callbacks""" + for node in importer.nodes: + for nodecls, triggers in self.__post_triggers.items(): + if isinstance(node, nodecls): + for func in triggers: + func(node, importer) + + +NODE_FACTORY = NodeFactory() + + +class GroupNode(BaseNode): + """Object representing a HDF5 group node""" + + @property + def icon_name(self): + """Icon name associated to node""" + return "h5group.svg" + + def collect_children(self, node_dict: dict[str, BaseNode]): + """Construct tree""" + for dset in self.dset.values(): + try: + child_cls = NODE_FACTORY.get(dset) + if child_cls is not None: + child = child_cls(self.h5file, dset.name) + node_dict[child.id] = child + self.children.append(child) + if isinstance(child, GroupNode): + child.collect_children(node_dict) + child.collect_attributes() + except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc: + # Skip datasets that can't be processed due to various issues + print(f"Warning: Skipping dataset '{dset.name}' due to error: {exc}") + continue + + +class RootNode(GroupNode): + """Object representing a HDF5 root node""" + + def __init__(self, h5file): + super().__init__(h5file, "/") + + @property + def icon_name(self): + """Icon name associated to node""" + return "h5file.svg" + + @property + def name(self): + """Return node name, constructed from dataset name""" + return osp.basename(self.h5file.filename) + + @property + def description(self): + """Return node description""" + return self.h5file.filename diff --git a/sigimax/h5/generic.py b/sigimax/h5/generic.py new file mode 100644 index 0000000..c49d7c5 --- /dev/null +++ b/sigimax/h5/generic.py @@ -0,0 +1,580 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX Generic HDF5 format support +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +import h5py +import numpy as np +from guidata.utils.misc import to_string +from sigima.objects import create_image, create_signal + +from sigimax.h5 import common, utils + +# ============================================================================= +# Encoding and Data Reading Utilities +# ============================================================================= + + +def safe_decode_bytes(data, fallback=""): + """Safely decode bytes to string using multiple encoding strategies.""" + if isinstance(data, str): + return data + if not isinstance(data, bytes): + return str(data) + + # Try encodings in order of preference + for encoding in ["utf-8", "latin1", "cp1252", "iso-8859-1", "ascii"]: + try: + decoded = data.decode(encoding) + # For legacy encodings, validate the result looks reasonable + if encoding in ["latin1", "cp1252", "iso-8859-1"]: + if _is_reasonable_text(decoded): + return decoded + else: + return decoded + except (UnicodeDecodeError, UnicodeError): + continue + + # Final fallback with replacement characters + try: + return data.decode("utf-8", errors="replace") + except Exception: # pylint: disable=broad-except + return fallback + + +def _is_reasonable_text(text): + """Check if decoded text looks reasonable (mostly printable).""" + if not text: + return True + + printable_chars = sum(1 for c in text if c.isprintable() or c.isspace()) + ratio = printable_chars / len(text) + + # Accept if mostly printable or short enough to be likely text + return ratio >= 0.8 or len(text) < 20 + + +def safe_read_dataset(dset, fallback_data=None): + """Safely read HDF5 dataset with encoding error handling.""" + try: + return dset[()] + except UnicodeDecodeError: + # Try alternative reading strategies for problematic encodings + return _try_alternative_read(dset, fallback_data) + except (TypeError, ValueError, OSError): + return fallback_data + + +def _try_alternative_read(dset, fallback_data): + """Try alternative strategies to read datasets with encoding issues.""" + strategies = [ + lambda d: d.asstr()[()], # Try string conversion + lambda d: d.astype("S")[()], # Try reading as bytes + ] + + for strategy in strategies: + try: + return strategy(dset) + except Exception: # pylint: disable=broad-except + continue + + return fallback_data + + +# ============================================================================= +# Text Formatting Utilities +# ============================================================================= + + +def format_text_data(data): + """Format various types of data for text display.""" + if data is None: + return "" + + try: + return to_string(data) + except (UnicodeDecodeError, UnicodeError): + return _handle_encoding_issues(data) + + +def _handle_encoding_issues(data): + """Handle data with encoding issues.""" + if isinstance(data, bytes): + return safe_decode_bytes(data) + + if isinstance(data, np.ndarray): + if data.dtype.kind in ["S", "a", "U"]: # String arrays + return _format_string_array(data) + if data.dtype.names: # Compound data + return _format_compound_data(data) + + return f"" + + +def _format_string_array(data): + """Format string arrays with encoding handling.""" + try: + if data.size == 1: + return safe_decode_bytes(data.item()) + # Show first few elements + items = [] + for i, item in enumerate(data.flat): + if i >= 5: + items.append("...") + break + items.append(safe_decode_bytes(item)) + return f"[{', '.join(items)}]" + except Exception: # pylint: disable=broad-except + return f"" + + +def _format_compound_data(data): + """Format compound data with encoding handling.""" + try: + result_parts = [] + for field_name in data.dtype.names: + field_data = data[field_name] + if hasattr(field_data, "item"): + field_data = field_data.item() + + if isinstance(field_data, bytes): + field_value = safe_decode_bytes(field_data) + else: + field_value = str(field_data) + + result_parts.append(f"{field_name}: {field_value}") + + return f"({', '.join(result_parts)})" + except Exception: # pylint: disable=broad-except + return f"" + + +# ============================================================================= +# Base Node Class +# ============================================================================= + + +class BaseGenericNode(common.BaseNode): + """Base class for generic HDF5 data nodes with encoding support.""" + + @classmethod + def match(cls, dset): + """Return True if h5 dataset matches this node pattern.""" + return not isinstance(dset, h5py.Group) + + @property + def icon_name(self): + """Icon name associated to node.""" + return "h5scalar.svg" + + @property + def data(self): + """Data associated to node, if available.""" + return safe_read_dataset(self.dset, fallback_data=None) + + @property + def dtype_str(self): + """Return string representation of node data type.""" + try: + return str(self.dset.dtype) + except (UnicodeDecodeError, TypeError, ValueError): + if self.data is None: + return "unknown" + try: + return str(self.data.dtype) + except Exception: # pylint: disable=broad-except + return "unknown" + + @property + def text(self): + """Return node textual representation.""" + return format_text_data(self.data) + + +# ============================================================================= +# Specialized Node Classes +# ============================================================================= + + +class GenericScalarNode(BaseGenericNode): + """Node for scalar HDF5 data.""" + + @classmethod + def match(cls, dset): + """Match scalar numeric data.""" + if not super().match(dset): + return False + data = safe_read_dataset(dset) + return ( + data is not None + and isinstance(data, np.generic) + and utils.is_supported_num_dtype(data) + ) + + +class GenericTextNode(BaseGenericNode): + """Node for text/string HDF5 data.""" + + @classmethod + def match(cls, dset): + """Match text or string data.""" + if not super().match(dset): + return False + data = safe_read_dataset(dset) + if data is None: + # Try to match based on dtype for unreadable data + try: + dtype = dset.dtype + return dtype.kind in ["S", "a", "U"] or "str" in str(dtype) + except Exception: # pylint: disable=broad-except + return False + return isinstance(data, bytes) or utils.is_supported_str_dtype(data) + + @property + def dtype_str(self): + """Return simplified dtype for text data.""" + return "string" + + @property + def text(self): + """Return formatted text with special handling for single arrays.""" + if self.data is None: + return "" + + try: + if utils.is_single_str_array(self.data): + item = self.data[0] + return safe_decode_bytes(item) if isinstance(item, bytes) else str(item) + return format_text_data(self.data) + except (UnicodeDecodeError, UnicodeError): + return _handle_text_encoding_issues(self.data) + + +def _handle_text_encoding_issues(data): + """Handle encoding issues specific to text nodes.""" + if isinstance(data, bytes): + return safe_decode_bytes(data) + if isinstance(data, np.ndarray) and data.dtype.kind in ["S", "a"]: + try: + if data.size == 1: + return safe_decode_bytes(data.item()) + decoded = [safe_decode_bytes(item) for item in data.flat] + return str(decoded[:10]) # Show first 10 elements + except Exception: # pylint: disable=broad-except + return f"" + return "" + + +class GenericArrayNode(BaseGenericNode): + """Node for array HDF5 data, including numeric arrays from compound data.""" + + IS_ARRAY = True + + @classmethod + def match(cls, dset): + """Match numeric array data, including convertible compound data.""" + if not super().match(dset): + return False + data = safe_read_dataset(dset) + + if data is None: + return False + + # First check direct numeric arrays + if ( + utils.is_supported_num_dtype(data) + and isinstance(data, np.ndarray) + and len(data.shape) in (1, 2) + ): + return True + + # Then check compound data that can be converted to numeric arrays + if ( + isinstance(data, np.ndarray) + and hasattr(data.dtype, "names") + and data.dtype.names is not None + ): + return cls._can_convert_compound_to_numeric(data) + + return False + + @classmethod + def _can_convert_compound_to_numeric(cls, data): + """Check if compound data can be converted to a supported numeric array.""" + try: + numeric_array = cls.extract_numeric_from_compound(data) + return ( + numeric_array is not None + and utils.is_supported_num_dtype(numeric_array) + and isinstance(numeric_array, np.ndarray) + and len(numeric_array.shape) in (1, 2) + ) + except Exception: # pylint: disable=broad-except + return False + + @classmethod + def extract_numeric_from_compound(cls, data): + """Extract a numeric array from compound data.""" + if not (hasattr(data.dtype, "names") and data.dtype.names): + return None + + # Find ALL fields and check if they are numeric + all_fields = list(data.dtype.names) + numeric_fields = [] + for field_name in all_fields: + field_dtype = data.dtype.fields[field_name][0] + if np.issubdtype(field_dtype, np.number): + numeric_fields.append(field_name) + + # Only convert if ALL fields are numeric (preserve all information) + # or if there's a single numeric field and no important string data + if len(numeric_fields) == 0: + return None + + if len(numeric_fields) != len(all_fields): + # Mixed data - check if non-numeric fields contain meaningful data + for field_name in all_fields: + if field_name not in numeric_fields: + field_data = data[field_name] + # If there's meaningful string data, don't convert + if cls._has_meaningful_string_data(field_data): + return None + + try: + # If single numeric field, extract it directly + if len(numeric_fields) == 1: + return data[numeric_fields[0]] + + # Multiple numeric fields: stack them if compatible shapes + field_data = [data[field] for field in numeric_fields] + + # Check if all fields have the same shape + shapes = [arr.shape for arr in field_data] + if len(set(shapes)) == 1: + # Stack along new axis to create 2D array + return np.stack(field_data, axis=-1) + + except Exception: # pylint: disable=broad-except + pass + return None + + @classmethod + def _has_meaningful_string_data(cls, field_data): + """Check if string field contains meaningful data worth preserving.""" + try: + if hasattr(field_data, "flat"): + # Check if most entries are non-empty and meaningful + non_empty_count = 0 + for item in field_data.flat: + if isinstance(item, bytes): + decoded = item.decode("utf-8", errors="ignore").strip() + if decoded and len(decoded) > 0: + non_empty_count += 1 + elif isinstance(item, str) and item.strip(): + non_empty_count += 1 + + # If most entries have meaningful content, preserve it + return non_empty_count / field_data.size > 0.5 + return True # Default to preserving unknown string data + except Exception: # pylint: disable=broad-except + return True # Conservative: preserve if we can't determine + + @property + def data(self): + """Data associated to node, if available.""" + raw_data = safe_read_dataset(self.dset, fallback_data=None) + + # If this is compound data, try to extract numeric array + if ( + raw_data is not None + and isinstance(raw_data, np.ndarray) + and hasattr(raw_data.dtype, "names") + and raw_data.dtype.names is not None + ): + numeric_data = self.extract_numeric_from_compound(raw_data) + if numeric_data is not None: + return numeric_data + + return raw_data + + def is_supported(self) -> bool: + """Return True if node is associated to supported data""" + return self.data.size > 1 + + @property + def __is_signal(self): + """Return True if array represents a signal""" + shape = self.data.shape + return len(shape) == 1 or shape[0] in (1, 2) or shape[1] in (1, 2) + + @property + def icon_name(self): + """Icon name associated to node""" + if self.is_supported(): + return "signal.svg" if self.__is_signal else "image.svg" + return "h5array.svg" + + @property + def shape_str(self): + """Return string representation of node shape, if any""" + return " x ".join([str(size) for size in self.data.shape]) + + @property + def dtype_str(self): + """Return string representation of node data type, if any""" + return str(self.data.dtype) + + @property + def text(self): + """Return node textual representation""" + return str(self.data) + + def create_native_object(self): + """Create native object, if supported""" + if self.__is_signal: + obj = create_signal(self.object_title) + try: + self.set_signal_data(obj) + except ValueError: + obj = None + else: + obj = create_image(self.object_title) + try: + self.set_image_data(obj) + except ValueError: + obj = None + return obj + + +class GenericCompoundNode(BaseGenericNode): + """Node for compound/structured HDF5 data that can't convert to numeric arrays.""" + + IS_ARRAY = True + + @classmethod + def match(cls, dset): + """Match compound/structured data that cannot be converted to numeric arrays.""" + if not super().match(dset): + return False + + data = safe_read_dataset(dset) + if data is None: + # Try to match based on dtype if we can't read the data + try: + return dset.dtype.names is not None + except Exception: # pylint: disable=broad-except + return False + + # Check if it's compound data (structured array) + if not ( + isinstance(data, np.ndarray) + and hasattr(data.dtype, "names") + and data.dtype.names is not None + ): + return False + + # IMPORTANT: Only match if GenericArrayNode cannot handle this data + # Try to convert to a numeric array first + if cls._can_convert_to_numeric_array(data): + return False # Let GenericArrayNode handle it + + return True # We handle compound data that can't be converted + + @classmethod + def _can_convert_to_numeric_array(cls, data): + """Check if compound data can be converted to a numeric array.""" + try: + # Try to extract numeric fields and create a pure numeric array + numeric_array = cls.extract_numeric_array(data) + if numeric_array is None: + return False + + # Check if the resulting array would be supported by GenericArrayNode + return ( + utils.is_supported_num_dtype(numeric_array) + and isinstance(numeric_array, np.ndarray) + and len(numeric_array.shape) in (1, 2) + ) + except Exception: # pylint: disable=broad-except + return False + + @classmethod + def extract_numeric_array(cls, data): + """Try to extract a pure numeric array from compound data.""" + # Use the same logic as GenericArrayNode + return GenericArrayNode.extract_numeric_from_compound(data) + + @property + def dtype_str(self): + """Return detailed compound dtype information.""" + try: + dtype = self.dset.dtype + if dtype.names: + field_info = [] + for name in dtype.names: + field_dtype = dtype.fields[name][0] + field_info.append(f"{name}: {field_dtype}") + return f"compound({', '.join(field_info)})" + return str(dtype) + except Exception: # pylint: disable=broad-except + return super().dtype_str + + @property + def text(self): + """Return formatted compound data.""" + if self.data is None: + return "" + try: + return self._format_compound_data() + except Exception: # pylint: disable=broad-except + return f"" + + def _format_compound_data(self): + """Format compound data for display.""" + if not (hasattr(self.data.dtype, "names") and self.data.dtype.names): + return super().text + if self.data.size == 1: + return self._format_single_record() + return self._format_multiple_records() + + def _format_single_record(self): + """Format a single compound record.""" + parts = [] + for field_name in self.data.dtype.names: + field_value = self.data[field_name].item() + if isinstance(field_value, bytes): + field_value = safe_decode_bytes(field_value) + parts.append(f"{field_name}: {field_value}") + return f"({', '.join(parts)})" + + def _format_multiple_records(self): + """Format multiple compound records.""" + records = [] + for i, record in enumerate(self.data.flat): + if i >= 3: # Show max 3 records + records.append("...") + break + + parts = [] + for field_name in self.data.dtype.names: + field_value = record[field_name] + if isinstance(field_value, bytes): + field_value = safe_decode_bytes(field_value) + parts.append(f"{field_name}: {field_value}") + records.append(f"({', '.join(parts)})") + + return f"[{', '.join(records)}]" + + +# ============================================================================= +# Node Registration +# ============================================================================= + +# Register all node types with the factory +common.NODE_FACTORY.register(GenericScalarNode, is_generic=True) +common.NODE_FACTORY.register(GenericTextNode, is_generic=True) +common.NODE_FACTORY.register(GenericArrayNode, is_generic=True) +common.NODE_FACTORY.register(GenericCompoundNode, is_generic=True) diff --git a/sigimax/h5/utils.py b/sigimax/h5/utils.py new file mode 100644 index 0000000..0678ded --- /dev/null +++ b/sigimax/h5/utils.py @@ -0,0 +1,95 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +SigimaX Utilities for exogenous HDF5 format support +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +import numpy as np +from guidata.utils.misc import to_string + + +def fix_ldata(fuzzy): + """Fix label data""" + if fuzzy is not None: + if fuzzy and isinstance(fuzzy, np.void) and len(fuzzy) > 1: + # Shouldn't happen (invalid LMJ fmt) + fuzzy = fuzzy[0] + if isinstance(fuzzy, (np.bytes_, bytes)): + fuzzy = to_string(fuzzy) + if isinstance(fuzzy, str): + return fuzzy + return "" + + +def fix_ndata(fuzzy): + """Fix numeric data""" + if fuzzy is not None: + if fuzzy and isinstance(fuzzy, np.void) and len(fuzzy) > 1: + # Shouldn't happen (invalid LMJ fmt) + fuzzy = fuzzy[0] + try: + if float(fuzzy) == int(fuzzy): + return int(fuzzy) + return float(fuzzy) + except (TypeError, ValueError): + pass + return None + + +def process_scalar_value(dset, name, callback): + """Process dataset numeric/str value `name`""" + try: + scdata = dset[name][()] + if isinstance(scdata, np.ndarray): + scdata = scdata[0] + if scdata is not None: + return callback(scdata) + except (KeyError, ValueError): + pass + return None + + +def process_label(dset, name): + """Process dataset label `name`""" + try: + ldata = dset[name][()] + if ldata is not None: + xldata, yldata, zldata = "", "", "" + if len(ldata) == 2: + xldata, yldata = ldata + elif len(ldata) == 3: + xldata, yldata, zldata = ldata + return fix_ldata(xldata), fix_ldata(yldata), fix_ldata(zldata) + except KeyError: + pass + return "", "", "" + + +def process_xy_values(dset, name): + """Process dataset x,y values `name`""" + try: + ldata = dset[name][()] + if ldata is not None: + return fix_ndata(ldata[0]), fix_ndata(ldata[1]) + except (KeyError, ValueError): + pass + return None, None + + +def is_supported_num_dtype(data): + """Return True if data type is a numerical type supported by SigimaX""" + return data.dtype.name.startswith(("int", "uint", "float", "complex")) + + +def is_single_str_array(data): + """Return True if data is a single-item string array""" + return ( + isinstance(data, np.generic) and data.shape == (1,) and isinstance(data[0], str) + ) + + +def is_supported_str_dtype(data): + """Return True if data type is a string type supported by preview""" + return data.dtype.name.startswith("string") or is_single_str_array(data) diff --git a/sigimax/locale/fr/LC_MESSAGES/sigimax.po b/sigimax/locale/fr/LC_MESSAGES/sigimax.po new file mode 100644 index 0000000..fb0e551 --- /dev/null +++ b/sigimax/locale/fr/LC_MESSAGES/sigimax.po @@ -0,0 +1,648 @@ +# French translations for sigimax. +# Copyright (C) 2026 DataLab Platform Developers +# This file is distributed under the same license as the sigimax project. +# +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" + +msgid "" +"SigimaX is a GUI library working with Sigima and PlotPyStack.\n" +" It provides a App configuration system, a generic MainWindow class and a\n" +" set of widgets to build applications on top of Sigima and PlotPyStack." +msgstr "" +"SigimaX est une bibliothèque graphique fonctionnant avec Sigima et PlotPyStack.\n" +" Elle fournit un système de configuration d'application, une classe MainWindow générique et un\n" +" ensemble de widgets pour construire des applications basées sur Sigima et PlotPyStack." + +#, fuzzy +msgid "General" +msgstr "Générer" + +msgid "Console" +msgstr "Console" + +msgid "I/O" +msgstr "" + +msgid "Processing" +msgstr "" + +#, fuzzy +msgid "Visualization" +msgstr "Quitter l'application" + +msgid "Warning" +msgstr "Attention" + +#, python-format +msgid "Available memory is below %d MB.

Do you want to continue?" +msgstr "La mémoire disponible est inférieure à %d Mo.

Voulez-vous continuer ?" + +msgid "This software is in the beta stage of its release cycle. The focus of beta testing is providing a feature complete software for users interested in trying new features before the final release. However, beta software may not behave as expected and will probably have more bugs or performance issues than completed software." +msgstr "Ce logiciel est au stade bêta de son cycle de publication. L'objectif des tests bêta est de fournir un logiciel complet en fonctionnalités pour les utilisateurs souhaitant essayer de nouvelles fonctionnalités avant la version finale. Cependant, un logiciel bêta peut ne pas se comporter comme prévu et aura probablement plus de bogues ou de problèmes de performances qu'un logiciel finalisé." + +msgid "This software is in the alpha stage of its release cycle. The focus of alpha testing is providing an incomplete software for early testing of specific features by users. Please note that alpha software was not thoroughly tested by the developer before it is released." +msgstr "Ce logiciel est au stade alpha de son cycle de publication. L'objectif des tests alpha est de fournir un logiciel incomplet pour des tests précoces de fonctionnalités spécifiques par les utilisateurs. Veuillez noter que le logiciel alpha n'a pas été testé de manière approfondie par le développeur avant sa publication." + +msgid "This is not a stable release." +msgstr "Ceci n'est pas une version stable." + +msgid "Do you want to see available log files?" +msgstr "Voulez-vous voir les fichiers journaux disponibles ?" + +#, python-format +msgid "Welcome to %s!" +msgstr "Bienvenue dans %s !" + +msgid "Main Toolbar" +msgstr "Barre d'outils principale" + +msgid "Open HDF5 files..." +msgstr "Ouvrir des fichiers HDF5..." + +msgid "Open one or more HDF5 files" +msgstr "Ouvrir un ou plusieurs fichiers HDF5" + +msgid "Save to HDF5 file..." +msgstr "Enregistrer dans un fichier HDF5..." + +msgid "Save to HDF5 file" +msgstr "Enregistrer dans un fichier HDF5" + +msgid "Browse HDF5 file..." +msgstr "Parcourir un fichier HDF5..." + +msgid "Browse an HDF5 file" +msgstr "Parcourir un fichier HDF5" + +msgid "Hide window" +msgstr "Masquer la fenêtre" + +#, fuzzy, python-format +msgid "Hide %s window" +msgstr "Masquer la fenêtre" + +msgid "Quit" +msgstr "Quitter" + +msgid "Quit application" +msgstr "Quitter l'application" + +msgid "&File" +msgstr "&Fichier" + +msgid "&View" +msgstr "&Affichage" + +#, python-format +msgid "" +"Welcome to %s console!\n" +"You can access the main window with the 'win' variable.\n" +"Modules imported at startup: os, sys, os.path as osp, time, numpy as np, scipy.signal as sps, scipy.ndimage as spi" +msgstr "" +"Bienvenue dans la console %s !\n" +"Vous pouvez accéder à la fenêtre principale avec la variable 'win'.\n" +"Modules importés au démarrage : os, sys, os.path as osp, time, numpy as np, scipy.signal as sps, scipy.ndimage as spi" + +msgid "Online documentation" +msgstr "Documentation en ligne" + +msgid "PDF documentation" +msgstr "Documentation PDF" + +msgid "Test segfault/Python error" +msgstr "Tester erreur de segmentation/Python" + +msgid "Log files" +msgstr "Journaux de bord" + +msgid "Project home page" +msgstr "Page d'accueil du projet" + +msgid "Bug report or feature request" +msgstr "Rapport d'anomalie ou demande de nouvelle fonctionnalité" + +msgid "About..." +msgstr "À propos..." + +msgid "Save" +msgstr "Enregistrer" + +msgid "Open" +msgstr "Ouvrir" + +msgid "HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)" +msgstr "Fichiers HDF5 (*.h5 *.hdf5 *.hdf *.he5);;Tous les fichiers (*)" + +msgid "Do you want to clear current workspace before importing data from HDF5 files?" +msgstr "Voulez-vous effacer l'espace de travail actuel avant d'importer des données depuis des fichiers HDF5 ?" + +msgid "Note: If you choose No, when importing workspace files, objects with conflicting identifiers will have their processing history lost (features like 'Show source' and 'Recompute' will not work for those objects). Non-conflicting objects will preserve their processing history." +msgstr "Note : Si vous choisissez Non, lors de l'importation de fichiers d'espace de travail, les objets avec des identifiants en conflit perdront leur historique de traitement (les fonctionnalités telles que 'Afficher la source' et 'Recalculer' ne fonctionneront pas pour ces objets). Les objets sans conflit conserveront leur historique de traitement." + +#, python-format +msgid "Choosing to ignore this message will prevent it from being displayed again, and will use the current setting (%s)." +msgstr "Choisir d'ignorer ce message empêchera son réaffichage et utilisera le paramètre actuel (%s)." + +msgid "Yes" +msgstr "Oui" + +msgid "No" +msgstr "Non" + +#, python-format +msgid "%d object(s) imported successfully" +msgstr "%d objet(s) importé(s) avec succès" + +msgid "Home page" +msgstr "Page d'accueil" + +msgid "Documentation" +msgstr "Documentation" + +msgid "Support" +msgstr "Support" + +msgid "Developed and maintained by DataLab open-source project team" +msgstr "Développé et maintenu par l'équipe du projet open-source DataLab" + +msgid "About" +msgstr "À propos" + +msgid "Do you want to save all signals and images to an HDF5 file before quitting the application?" +msgstr "Voulez-vous enregistrer tous les signaux et images dans un fichier HDF5 avant de quitter l'application ?" + +msgid "Curve Viewer" +msgstr "Visionneuse de courbes" + +msgid "&Tools" +msgstr "&Outils" + +msgid "Generate sine wave" +msgstr "Générer une onde sinusoïddale" + +msgid "Generate a sample sine wave and display it" +msgstr "Générer un exemple d'onde sinusoïddale et l'afficher" + +msgid "Clear plot" +msgstr "Effacer le graphique" + +msgid "Remove all curves from the plot" +msgstr "Supprimer toutes les courbes du graphique" + +msgid "Show configuration" +msgstr "Afficher la configuration" + +msgid "Print all current configuration options to the console" +msgstr "Afficher toutes les options de configuration actuelles dans la console" + +msgid "MyApp Tools" +msgstr "Outils MyApp" + +msgid "Sine" +msgstr "Sinus" + +msgid "Generate a sine wave" +msgstr "Générer une onde sinusoïddale" + +#, python-format +msgid "Generated sine wave with %d points" +msgstr "Onde sinusoïddale générée avec %d points" + +msgid "Plot cleared" +msgstr "Graphique effacé" + +msgid "New project" +msgstr "Nouveau projet" + +msgid "Create a new empty project" +msgstr "Créer un nouveau projet vide" + +msgid "Import CSV..." +msgstr "Importer CSV..." + +msgid "Import data from a CSV file" +msgstr "Importer des données depuis un fichier CSV" + +msgid "Web API status" +msgstr "État de l'API Web" + +msgid "Show Web API connection status" +msgstr "Afficher l'état de connexion de l'API Web" + +msgid "Preferences..." +msgstr "Préférences..." + +msgid "Edit application preferences" +msgstr "Modifier les préférences de l'application" + +msgid "Release notes" +msgstr "Notes de version" + +msgid "Show release notes" +msgstr "Afficher les notes de version" + +msgid "Settings..." +msgstr "Paramètres..." + +msgid "Open settings dialog" +msgstr "Ouvrir la boîte de dialogue des paramètres" + +msgid "My Action" +msgstr "Mon action" + +msgid "A custom action" +msgstr "Une action personnalisée" + +msgid "Cancel" +msgstr "Annuler" + +msgid "Context" +msgstr "Contexte" + +msgid "Error:" +msgstr "Erreur :" + +#, python-format +msgid "The file %s could not be read:" +msgstr "Le fichier %s n'a pas pu être lu :" + +#, python-format +msgid "The file %s could not be written:" +msgstr "Le fichier %s n'a pas pu être écrit :" + +msgid "in this folder" +msgstr "dans ce dossier" + +msgid "Open tab menu" +msgstr "Ouvrir le menu de l'onglet" + +msgid "Contents of file" +msgstr "Contenu du fichier" + +msgid "Polymomial fit" +msgstr "Ajustement polynomial" + +msgid "Amplitude" +msgstr "Amplitude" + +msgid "Base line" +msgstr "Ligne de base" + +msgid "Std-dev" +msgstr "Écart-type" + +msgid "Mean" +msgstr "Moyenne" + +msgid "Gaussian fit" +msgstr "Ajustement gaussien" + +msgid "Lorentzian fit" +msgstr "Ajustement lorentzien" + +msgid "Voigt fit" +msgstr "Ajustement de Voigt" + +msgid "Y0" +msgstr "Y0" + +msgid "Multi-Gaussian fit" +msgstr "Ajustement multi-gaussien" + +msgid "Multi-Lorentzian fit" +msgstr "Ajustement multi-lorentzien" + +msgid "A coefficient" +msgstr "Coefficient A" + +msgid "B coefficient" +msgstr "Coefficient B" + +msgid "y0 constant" +msgstr "Constante y0" + +msgid "Exponential fit" +msgstr "Ajustement exponentiel" + +msgid "Frequency" +msgstr "Fréquence" + +msgid "Phase" +msgstr "Phase" + +msgid "Continuous component" +msgstr "Composante continue" + +msgid "Sinusoidal fit" +msgstr "Ajustement sinusoïdal" + +msgid "CDF fit" +msgstr "Ajustement CDF" + +msgid "Scale factor" +msgstr "" + +msgid "Width factor" +msgstr "Facteur de largeur" + +msgid "Planckian fit" +msgstr "Ajustement planckien" + +msgid "Left amplitude" +msgstr "Amplitude gauche" + +msgid "Right amplitude" +msgstr "Amplitude droite" + +msgid "Left width" +msgstr "Largeur gauche" + +msgid "Right width" +msgstr "Largeur droite" + +msgid "Center" +msgstr "Centre" + +msgid "Left baseline" +msgstr "Ligne de base gauche" + +msgid "Right baseline" +msgstr "Ligne de base droite" + +msgid "Two half-Gaussian fit" +msgstr "Ajustement bi-demi-gaussien" + +msgid "Center position" +msgstr "Position du centre" + +#, fuzzy +msgid "Left rate" +msgstr "Amplitude gauche" + +#, fuzzy +msgid "Right rate" +msgstr "Amplitude droite" + +msgid "Piecewise exponential (raise-decay) fit" +msgstr "Ajustement exponentiel par morceaux (montée-décroissance)" + +msgid "Collapse all" +msgstr "Tout réduire" + +msgid "Expand all" +msgstr "Tout développer" + +msgid "Restore" +msgstr "Restaurer" + +msgid "Restore original tree layout" +msgstr "Restaurer la disposition originale de l'arborescence" + +msgid "Collapse selection" +msgstr "Réduire la sélection" + +msgid "Expand selection" +msgstr "Développer la sélection" + +msgid "HDF5 Browser" +msgstr "Explorateur HDF5" + +msgid "Value" +msgstr "Valeur" + +msgid "Name" +msgstr "Nom" + +msgid "Size" +msgstr "Taille" + +msgid "Type" +msgstr "Type" + +msgid "Unsupported data" +msgstr "Données non prises en charge" + +msgid "Group" +msgstr "Groupe" + +msgid "Attributes" +msgstr "Attributs" + +msgid "Show array" +msgstr "Afficher le tableau" + +msgid "Path" +msgstr "Chemin" + +msgid "Description" +msgstr "Description" + +msgid "Textual preview" +msgstr "Aperçu textuel" + +msgid "Close" +msgstr "Fermer" + +msgid "Select HDF5 file" +msgstr "Sélectionner un fichier HDF5" + +msgid "Check all" +msgstr "Tout cocher" + +msgid "Uncheck all" +msgstr "Tout décocher" + +msgid "Show only supported data" +msgstr "Afficher uniquement les données prises en charge" + +msgid "Show values" +msgstr "Afficher les valeurs" + +msgid "Image background selection" +msgstr "Sélection du fond d'image" + +msgid "Background area" +msgstr "Zone de fond" + +msgid "Background value:" +msgstr "Valeur du fond :" + +msgid "Log files were generated during current session." +msgstr "Des fichiers journaux ont été générés pendant la session en cours." + +msgid "Log files were generated during last session." +msgstr "Des fichiers journaux ont été générés pendant la dernière session." + +msgid "Log files are currently empty." +msgstr "Les fichiers journaux sont actuellement vides." + +msgid "Signal baseline selection" +msgstr "Sélection de la ligne de base du signal" + +msgid "Select X value with cursor" +msgstr "Sélectionner la valeur X avec le curseur" + +msgid "Select Y value with cursor" +msgstr "Sélectionner la valeur Y avec le curseur" + +msgid "Apply" +msgstr "Appliquer" + +msgid "Apply cursor position" +msgstr "Appliquer la position du curseur" + +msgid "Cursor position" +msgstr "Position du curseur" + +msgid "Minimum distance:" +msgstr "Distance minimale :" + +msgid "Signal peak detection" +msgstr "Détection de pics du signal" + +msgid "Peaks:" +msgstr "Pics :" + +msgid "Internal console" +msgstr "Console interne" + +msgid "" +"Click to show the internal console.\n" +"The icon will turn red if an error or warning is logged." +msgstr "" +"Cliquer pour afficher la console interne.\n" +"L'icône deviendra rouge si une erreur ou un avertissement est enregistré." + +msgid "" +"Click to show the internal console.\n" +"An error or warning has been logged." +msgstr "" +"Cliquer pour afficher la console interne.\n" +"Une erreur ou un avertissement a été enregistré." + +msgid "" +"Click to show the internal console.\n" +"No error or warning has been logged." +msgstr "" +"Cliquer pour afficher la console interne.\n" +"Aucune erreur ou avertissement n'a été enregistré." + +msgid "Memory available:" +msgstr "Mémoire disponible :" + +msgid "Memory used:" +msgstr "Mémoire utilisée :" + +msgid "Alarm threshold:" +msgstr "Seuil d'alarme :" + +msgid "Memory:" +msgstr "Mémoire :" + +msgid "Error message" +msgstr "Message d'erreur" + +msgid "The following traceback may help to understand the problem:" +msgstr "Le traceback suivant peut aider à comprendre le problème :" + +msgid "Warning message" +msgstr "Message d'avertissement" + +msgid "Please take into account the following warning message:" +msgstr "Veuillez prendre en compte le message d'avertissement suivant :" + +msgid "An error has occured during the following context:" +msgstr "Une erreur s'est produite dans le contexte suivant :" + +msgid "Tip" +msgstr "Astuce" + +msgid "Please click on the 'Ignore' button to ignore this warning next time." +msgstr "Veuillez cliquer sur le bouton « Ignorer » pour ignorer cet avertissement la prochaine fois." + +msgid "Back" +msgstr "Précédent" + +msgid "Next" +msgstr "Suivant" + +msgid "Finish" +msgstr "Terminer" + +msgid "Welcome to the Example Wizard" +msgstr "Bienvenue dans l'assistant d'exemple" + +msgid "This wizard will guide you through the process of importing data." +msgstr "Cet assistant vous guidera tout au long du processus d'importation de données." + +msgid "Select the Source of the Data" +msgstr "Sélectionner la source des données" + +msgid "Select the source of the data to be imported (clipboard or file)." +msgstr "Sélectionner la source des données à importer (presse-papiers ou fichier)." + +msgid "Clipboard" +msgstr "Presse-papiers" + +msgid "File" +msgstr "Fichier" + +msgid "Browse..." +msgstr "Parcourir..." + +msgid "Select the File to Import" +msgstr "Sélectionner le fichier à importer" + +msgid "CSV Files (*.csv);;Text Files (*.txt);;All Files (*)" +msgstr "Fichiers CSV (*.csv);;Fichiers texte (*.txt);;Tous les fichiers (*)" + +msgid "Error" +msgstr "Erreur" + +msgid "Please select the file to import." +msgstr "Veuillez sélectionner le fichier à importer." + +msgid "Example Wizard" +msgstr "Assistant d'exemple" + +msgid "Signal Viewer" +msgstr "Visionneuse de signaux" + +msgid "&Analysis" +msgstr "&Analyse" + +#, fuzzy +msgid "Run analysis" +msgstr "Analyse" + +msgid "Export report..." +msgstr "" + +msgid "Running analysis..." +msgstr "" + +msgid "Exporting report..." +msgstr "" + +#, fuzzy +msgid "Idle" +msgstr "Fichier" + +msgid "Generate signal" +msgstr "Générer un signal" + +msgid "Analysis" +msgstr "Analyse" + +msgid "Generate" +msgstr "Générer" + +msgid "Signal generated" +msgstr "Signal généré" + diff --git a/sigimax/mainwindow.py b/sigimax/mainwindow.py new file mode 100644 index 0000000..0d5f47c --- /dev/null +++ b/sigimax/mainwindow.py @@ -0,0 +1,1340 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Main window +=========== + +The :mod:`sigimax.mainwindow` module provides a generic main window for derived +applications. +It is designed to be flexible and extensible, allowing to easily add +new panels, actions, menus and toolbars. +It also provides a set of signals to communicate with other parts of the application. + +.. autoclass:: SGMXMainWindow + :members: +""" + +# pylint: disable=invalid-name # Allows short reference names like x, y, ... + +from __future__ import annotations + +__all__ = [ + "SGMXMainWindow", +] + +import abc +import base64 +import os +import os.path as osp +import sys +import time +import webbrowser +from typing import TYPE_CHECKING + +import numpy as np +import scipy.ndimage as spi +import scipy.signal as sps +from guidata import qthelpers as guidata_qth +from guidata.configtools import get_icon +from guidata.qthelpers import add_actions, create_action, exec_dialog +from guidata.widgets.console import DockableConsole +from plotpy import config as plotpy_config +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW +from qtpy.compat import getopenfilenames, getsavefilename + +from sigimax._metadata import __homeurl__, __version__ +from sigimax.config import ( + DEBUG, + MOD_DESC, + MOD_TITLE, + TEST_SEGFAULT_ERROR, + _, + get_conf, +) +from sigimax.env import execenv +from sigimax.h5 import H5Importer +from sigimax.utils import qthelpers as qth +from sigimax.utils.qthelpers import ( + add_corner_menu, + bring_to_front, + configure_menu_about_to_show, + qt_handle_error_message, +) +from sigimax.widgets import logviewer, status +from sigimax.widgets.h5browser import H5BrowserDialog +from sigimax.widgets.plotdock import DockablePlotWidget +from sigimax.widgets.warningerror import go_to_error + +if TYPE_CHECKING: + from typing import Literal + + +class SGMXMainWindowMeta(type(QW.QMainWindow), abc.ABCMeta): + """Mixed metaclass to avoid conflicts""" + + +class SGMXMainWindow(QW.QMainWindow, metaclass=SGMXMainWindowMeta): + """SigimaX generic main window + + Args: + console: enable internal console + hide_on_close: True to hide window on close + """ + + __instance = None + + #: Bump this whenever dock widget object names change, so that layouts saved + #: by an older version are discarded instead of being partially restored. + WINDOW_STATE_VERSION = 1 + + SIG_READY = QC.Signal() + SIG_SEND_OBJECT = QC.Signal(object) + SIG_SEND_OBJECTLIST = QC.Signal(object) + SIG_CLOSING = QC.Signal() + + @classmethod + def get_instance(cls, console=None, hide_on_close=False): + """Return the singleton instance for this window class.""" + if not isinstance(SGMXMainWindow.__instance, cls): + return cls(console, hide_on_close) + return SGMXMainWindow.__instance + + def __init__(self, console=None, hide_on_close=False): + """Initialize main window""" + SGMXMainWindow.__instance = self + super().__init__() + conf = get_conf() + self.setObjectName(conf.app_name.get()) + self.setWindowIcon(get_icon(conf.app_logo_path.get())) + + execenv.log(self, "Starting initialization") + + self.ready_flag = True + + self.hide_on_close = hide_on_close + self.__old_size: tuple[int, int] | None = None + self.__memory_warning = False + self.memorystatus: status.MemoryStatus | None = None + + self.consolestatus: status.ConsoleStatus | None = None + self.console: DockableConsole | None = None + + self.main_toolbar: QW.QToolBar | None = None + self.tabwidget: QW.QTabWidget | None = None + self.tabmenu: QW.QMenu | None = None + self.docks: dict[QW.QWidget, QW.QDockWidget] = {} + + self.openh5_action: QW.QAction | None = None + self.saveh5_action: QW.QAction | None = None + self.browseh5_action: QW.QAction | None = None + self.quit_action: QW.QAction | None = None + self.showfirstonly_action: QW.QAction | None = None + self.showlabel_action: QW.QAction | None = None + + self.file_menu: QW.QMenu | None = None + self.view_menu: QW.QMenu | None = None + self.help_menu: QW.QMenu | None = None + + # Setup actions and menus + if console is None: + console = conf.console_enabled.get() + self._before_setup(console) + self._update_color_mode(startup=True) + + self.__is_modified = False + self.set_modified(False) + self.setup(console) + self._after_setup(console) + + self._restore_pos_and_size() + execenv.log(self, "Initialization done") + + def _before_setup(self, console: bool) -> None: + """Initialize derived-application state before :meth:`setup`. + + Args: + console: Whether the internal console will be created. + """ + + def _after_setup(self, console: bool) -> None: + """Finalize derived-application state after :meth:`setup`. + + Args: + console: Whether the internal console was created. + """ + + def _set_low_memory_state(self, state: bool) -> None: + """Set memory warning state""" + self.__memory_warning = state + + def confirm_memory_state(self) -> bool: # pragma: no cover + """Check memory warning state and eventually show a warning dialog + + Returns: + True if memory state is ok + """ + if not execenv.unattended and self.__memory_warning: + threshold = get_conf().available_memory_threshold.get() + answer = QW.QMessageBox.critical( + self, + _("Warning"), + _("Available memory is below %d MB.

Do you want to continue?") + % threshold, + QW.QMessageBox.Yes | QW.QMessageBox.No, + ) + return answer == QW.QMessageBox.Yes + return True + + def check_stable_release(self) -> None: # pragma: no cover + """Check if this is a stable release""" + conf = get_conf() + app_version = conf.app_version.get() + if app_version.replace(".", "").isdigit(): + # This is a stable release + return + if "b" in app_version: + # This is a beta release + rel = _( + "This software is in the beta stage of its release cycle. " + "The focus of beta testing is providing a feature complete " + "software for users interested in trying new features before " + "the final release. However, beta software may not behave as " + "expected and will probably have more bugs or performance issues " + "than completed software." + ) + else: + # This is an alpha release + rel = _( + "This software is in the alpha stage of its release cycle. " + "The focus of alpha testing is providing an incomplete software " + "for early testing of specific features by users. " + "Please note that alpha software was not thoroughly tested " + "by the developer before it is released." + ) + txtlist = [ + f"{conf.app_name.get()} v{app_version}:", + "", + _("This is not a stable release."), + "", + rel, + ] + if not execenv.unattended: + QW.QMessageBox.warning( + self, conf.app_name.get(), "
".join(txtlist), QW.QMessageBox.Ok + ) + + def check_for_previous_crash(self) -> None: # pragma: no cover + """Check for previous crash""" + conf = get_conf() + if execenv.unattended and not execenv.do_not_quit: + # Showing the log viewer for testing purpose (unattended mode) but only + # if option 'do_not_quit' is not set, to avoid blocking the test suite + self._show_logviewer() + elif execenv.do_not_quit: + # If 'do_not_quit' is set, we do not show any message box to avoid blocking + # the test suite + return + elif ( + conf.faulthandler_log_available.get() or conf.traceback_log_available.get() + ): + txt = "
".join( + [ + logviewer.get_log_prompt_message(), + "", + _("Do you want to see available log files?"), + ] + ) + btns = QW.QMessageBox.StandardButton.Yes | QW.QMessageBox.StandardButton.No + choice = QW.QMessageBox.warning(self, conf.app_name.get(), txt, btns) + if choice == QW.QMessageBox.StandardButton.Yes: + self._show_logviewer() + + def execute_post_show_actions(self) -> None: + """Execute post-show actions""" + self.check_stable_release() + self.check_for_previous_crash() + + def take_screenshot(self, name: str) -> None: # pragma: no cover + """Take main window screenshot""" + # For esthetic reasons, we set the central widget width to a lower value: + old_width = self.tabwidget.maximumWidth() + self.tabwidget.setMaximumWidth(500) + # To avoid having screenshot depending on memory status, we set demo mode ON: + self.memorystatus.set_demo_mode(True) + qth.grab_save_window(self, f"{name}") + # Restore previous state: + self.memorystatus.set_demo_mode(False) + self.tabwidget.setMaximumWidth(old_width) + + def take_menu_screenshots(self) -> None: # pragma: no cover + """Take menu screenshots""" + for name in ( + "file", + "view", + "help", + ): + menu = getattr(self, f"{name}_menu") + menu.popup(self.pos()) + qth.grab_save_window(menu, f"{name}_menu") + menu.close() + + # ------GUI setup + def _restore_pos_and_size(self) -> None: + """Restore main window position and size from configuration""" + conf = get_conf() + pos = conf.window_position.get() + if pos is not None: + posx, posy = pos + self.move(QC.QPoint(posx, posy)) + size = conf.window_size.get() + if size is None: + sgeo = self.screen().availableGeometry() + sw, sh = sgeo.width(), sgeo.height() + w = max(1200, min(1800, int(sw * 0.8))) + h = max(700, min(1100, int(sh * 0.8))) + size = (w, h) + if pos is None: + cx = sgeo.x() + (sw - w) // 2 + cy = sgeo.y() + (sh - h) // 2 + self.move(QC.QPoint(cx, cy)) + width, height = size + self.resize(QC.QSize(width, height)) + if pos is not None and size is not None: + sgeo = self.screen().availableGeometry() + out_inf = posx < -int(0.9 * width) or posy < -int(0.9 * height) + out_sup = posx > int(0.9 * sgeo.width()) or posy > int(0.9 * sgeo.height()) + if len(QW.QApplication.screens()) == 1 and (out_inf or out_sup): + # Main window is offscreen + posx = min(max(posx, 0), sgeo.width() - width) + posy = min(max(posy, 0), sgeo.height() - height) + self.move(QC.QPoint(posx, posy)) + + def _restore_state(self) -> None: + """Restore main window state from configuration""" + state = get_conf().window_state.get() + if state: + state = base64.b64decode(state) + self.restoreState(QC.QByteArray(state), self.WINDOW_STATE_VERSION) + for widget in self.children(): + if isinstance(widget, QW.QDockWidget): + self.restoreDockWidget(widget) + + def _save_pos_size_and_state(self) -> None: + """Save main window position, size and state to configuration""" + conf = get_conf() + is_maximized = self.windowState() == QC.Qt.WindowMaximized + conf.window_maximized.set(is_maximized) + if not is_maximized: + size = self.size() + conf.window_size.set((size.width(), size.height())) + pos = self.pos() + conf.window_position.set((pos.x(), pos.y())) + # Encoding window state into base64 string to avoid sending binary data + # to the configuration file: + state = self.saveState(self.WINDOW_STATE_VERSION).data() + conf.window_state.set(base64.b64encode(state).decode("ascii")) + + def setup(self, console: bool = False) -> None: + """Setup main window + + The sequence is fixed: derived applications extend it by overriding the + hooks it calls, not by overriding this method. :meth:`_restore_state` + must stay last, since dock widgets added afterwards keep their default + geometry instead of the persisted one. + + Args: + console: True to setup console + """ + self._configure_statusbar(console) + self._setup_global_actions() + self._setup_panels() + self._setup_central_widget() + self._add_menus() + if console: + self._setup_console() + self._setup_docks() + self._post_setup(console) + # Now that everything is set up, we can restore the window state: + self._restore_state() + + def _setup_panels(self) -> None: + """Create the application panels and views. + + Called after the global actions, so that panel toolbars are added after + the main toolbar, and before the central widget is set up. + """ + + def _setup_docks(self) -> None: + """Add the application dock widgets. + + Called before :meth:`_restore_state`, so that the persisted layout is + applied to them. + """ + + def _post_setup(self, console: bool) -> None: + """Finalize the user interface. + + Called once panels, central widget, menus, console and docks all exist. + + Args: + console: Whether the internal console was created. + """ + + def _configure_statusbar(self, console: bool) -> None: + """Configure status bar + + Args: + console: True if console is enabled + """ + conf = get_conf() + self.statusBar().showMessage(_("Welcome to %s!") % conf.app_name.get(), 5000) + if console: + # Console status + self.consolestatus = status.ConsoleStatus() + self.statusBar().addPermanentWidget(self.consolestatus) + for widget in self._get_extra_status_widgets(): + self.statusBar().addPermanentWidget(widget) + # Memory status + threshold = conf.available_memory_threshold.get() + self.memorystatus = status.MemoryStatus(threshold) + self.memorystatus.SIG_MEMORY_ALARM.connect(self._set_low_memory_state) + self.statusBar().addPermanentWidget(self.memorystatus) + + def _get_extra_status_widgets(self) -> list[QW.QWidget]: + """Return the application-specific status bar widgets. + + They are added between the console status and the memory status. + + Returns: + List of widgets + """ + return [] + + def _add_toolbar( + self, title: str, position: Literal["top", "bottom", "left", "right"], name: str + ) -> QW.QToolBar: + """Add toolbar to main window + + Args: + title: toolbar title + position: toolbar position + name: toolbar name (Qt object name) + """ + toolbar = QW.QToolBar(title, self) + toolbar.setObjectName(name) + area = getattr(QC.Qt, f"{position.capitalize()}ToolBarArea") + self.addToolBar(area, toolbar) + return toolbar + + def _setup_global_actions(self) -> None: + """Setup global actions""" + self._create_global_actions() + self.main_toolbar = self._add_toolbar(_("Main Toolbar"), "left", "main_toolbar") + add_actions(self.main_toolbar, self._get_main_toolbar_actions()) + + def _create_global_actions(self) -> None: + """Create global actions (H5, quit, etc.). + + Override in subclasses to create additional actions or replace + defaults. Call ``super()._create_global_actions()`` first to + create the standard H5 and quit actions. + """ + self.openh5_action = create_action( + self, + _("Open HDF5 files..."), + icon=get_icon("fileopen_h5.svg"), + tip=_("Open one or more HDF5 files"), + triggered=lambda checked=False: self.open_h5_files(import_all=True), + ) + self.saveh5_action = create_action( + self, + _("Save to HDF5 file..."), + icon=get_icon("filesave_h5.svg"), + tip=_("Save to HDF5 file"), + triggered=self.save_to_h5_file, + ) + self.browseh5_action = create_action( + self, + _("Browse HDF5 file..."), + icon=get_icon("h5browser.svg"), + tip=_("Browse an HDF5 file"), + triggered=lambda checked=False: self.open_h5_files(import_all=None), + ) + # Quit action for "File menu" (added when populating menu on demand) + if self.hide_on_close: + quit_text = _("Hide window") + quit_tip = _("Hide %s window") % get_conf().app_name.get() + else: + quit_text = _("Quit") + quit_tip = _("Quit application") + if sys.platform != "darwin": + # On macOS, the "Quit" action is automatically added to the application menu + self.quit_action = create_action( + self, + quit_text, + shortcut=QG.QKeySequence(QG.QKeySequence.Quit), + icon=get_icon("libre-gui-close.svg"), + tip=quit_tip, + triggered=self.close, + ) + + def _get_main_toolbar_actions(self) -> list[QW.QAction | None]: + """Return the list of actions for the main toolbar. + + Override in subclasses to customize which actions appear in the + main toolbar and their order. Return a list of :class:`QAction` + instances (or ``None`` for separators). + + Returns: + List of actions and separators + """ + return [ + self.openh5_action, + self.saveh5_action, + self.browseh5_action, + ] + + def _setup_central_widget(self) -> None: + """Setup central widget (main panel)""" + # Apply enhanced tab bar styling + self.tabwidget = QW.QTabWidget() + self.tabmenu = add_corner_menu(self.tabwidget) + tab_bar = self.tabwidget.tabBar() + font = tab_bar.font() + font.setPointSize(10) + tab_bar.setFont(font) + # Use QTimer to ensure tab bar is properly sized first + QC.QTimer.singleShot(0, self._update_tab_icon_size) + + self.setCentralWidget(self.tabwidget) + + def _update_tab_icon_size(self) -> None: + """Update tab icon size based on tab bar height""" + if self.tabwidget is not None: + tab_bar = self.tabwidget.tabBar() + if tab_bar.height() > 0: + # Use approximately 80% of tab height for icon size + icon_size = int(tab_bar.height() * 0.8) + self.tabwidget.setIconSize(QC.QSize(icon_size, icon_size)) + + @staticmethod + def __get_local_doc_path() -> str | None: + """Return local documentation path, if it exists. + + Uses the ``app_local_doc_path`` config field. When the path pattern + contains ``{lang}``, the system locale prefix is tried first (e.g. + ``fr``), then ``en`` as fallback. If the pattern does not contain + ``{lang}``, it is used as-is. + + Returns: + Resolved file path, or None if not configured / not found. + """ + pattern = get_conf().app_local_doc_path.get() + if not pattern: + return None + if "{lang}" in pattern: + locale = QC.QLocale.system().name() + for lang in (locale[:2], "en"): + path = pattern.format(lang=lang) + if osp.isfile(path): + return path + else: + if osp.isfile(pattern): + return pattern + return None + + def _get_menubar_layout(self) -> list[tuple[str, str]]: + """Return the menu bar layout, as ``(attribute name, title)`` pairs. + + Each pair creates a menu stored as ``self._menu``. + Override in subclasses to insert application menus while keeping the + standard ones, which the base implementation relies on. + + Returns: + Ordered list of (attribute name, menu title) + """ + return [("file", _("&File")), ("view", _("&View")), ("help", "?")] + + def _add_menus(self) -> None: + """Adding menus""" + for name, title in self._get_menubar_layout(): + setattr(self, f"{name}_menu", self.menuBar().addMenu(title)) + configure_menu_about_to_show(self.file_menu, self._update_file_menu) + configure_menu_about_to_show(self.view_menu, self._update_view_menu) + add_actions(self.help_menu, self._get_help_menu_actions()) + + def _update_console_show_mode(self) -> None: + """Update console show mode from configuration option + + Console show mode is whether the console is shown or not when an error occurs. + """ + if self.console is not None: + state = get_conf().show_console_on_error.get() + cdock = self.docks[self.console] + if not state and cdock.isVisible(): + cdock.hide() + if state: + self.console.exception_occurred.connect(self.console.show_console) + else: + self.console.exception_occurred.disconnect(self.console.show_console) + + def _get_console_namespace(self) -> dict[str, object]: + """Return the namespace dict exposed in the internal console. + + The default namespace provides ``win`` (the main window) and commonly + used scientific modules. Override in subclasses to add + application-specific variables. + + Returns: + Namespace dictionary + """ + return { + "win": self, + "np": np, + "sps": sps, + "spi": spi, + "os": os, + "sys": sys, + "osp": osp, + "time": time, + } + + def _get_console_message(self) -> str: + """Return the welcome message displayed in the internal console. + + Override in subclasses to provide application-specific examples. + + Returns: + Welcome message string + """ + app = get_conf().app_name.get() + return ( + _( + "Welcome to %s console!\n" + "You can access the main window with the 'win' variable.\n" + "Modules imported at startup: " + "os, sys, os.path as osp, time, " + "numpy as np, scipy.signal as sps, scipy.ndimage as spi" + ) + % app + ) + + def _configure_console(self) -> None: + """Configure application-specific console signals after creation.""" + + def _setup_console(self) -> None: + """Add an internal console""" + ns = self._get_console_namespace() + msg = self._get_console_message() + self.console = DockableConsole(self, namespace=ns, message=msg, debug=DEBUG) + self.console.setMaximumBlockCount(get_conf().console_max_line_count.get()) + self.console.go_to_error.connect(go_to_error) + cdock = self._add_dockwidget(self.console, _("Console"), name="console") + cdock.hide() + self._update_console_show_mode() + self.console.exception_occurred.connect(self.consolestatus.exception_occurred) + cdock.visibilityChanged.connect(self.consolestatus.console_visibility_changed) + self.consolestatus.SIG_SHOW_CONSOLE.connect(self.console.show_console) + self._configure_console() + + def _normalize_modified_state(self, state: bool) -> bool: + """Normalize a requested modified state for the application model.""" + return state + + def set_modified(self, state: bool = True) -> None: + """Set mainwindow modified state""" + state = self._normalize_modified_state(state) + self.__is_modified = state + if self.saveh5_action is not None: + self.saveh5_action.setEnabled(self._is_save_enabled()) + conf = get_conf() + title = conf.app_name.get() + ("*" if state else "") + if not conf.app_version.get().replace(".", "").isdigit(): + title += f" [{conf.app_version.get()}]" + self.setWindowTitle(title) + + def is_modified(self) -> bool: + """Return True if mainwindow is modified""" + return self.__is_modified + + def _add_dockwidget( + self, + child, + title: str, + *, + name: str | None = None, + key: QW.QWidget | None = None, + tabify_with: QW.QWidget | None = None, + ) -> QW.QDockWidget: + """Add a dock widget to the main window and register it in ``self.docks``. + + Args: + child: dockable widget, providing a ``create_dockwidget`` method + title: dock widget title, displayed to the user (translated) + name: stable Qt object name used to persist the dock layout. Defaults + to ``title``, but a non-translated name should be passed so that the + layout survives a language change. + key: key used to register the dock in ``self.docks``, when the logical + owner differs from the dockable widget itself. Defaults to ``child``. + tabify_with: key of an already registered dock to tabify with + + Returns: + Created dock widget + """ + dockwidget, location = child.create_dockwidget(title) + dockwidget.setObjectName(title if name is None else name) + self.addDockWidget(location, dockwidget) + if tabify_with is not None: + self.tabifyDockWidget(self.docks[tabify_with], dockwidget) + self.docks[child if key is None else key] = dockwidget + return dockwidget + + def _is_save_enabled(self) -> bool: + """Return whether the 'Save' action should be enabled. + + The base implementation returns ``True`` only when the workspace has + been modified and the derived application overrides + :meth:`save_h5_workspace`. Override in subclasses to add + domain-specific conditions (e.g., whether the workspace contains any + objects). + + Returns: + True if save action should be enabled + """ + return self._has_h5_workspace_persistence() and self.is_modified() + + def _get_file_menu_actions(self) -> list[QW.QAction | None]: + """Return the list of actions for the file menu. + + Override in subclasses to fully customize which actions appear and + their order. Return a list of :class:`QAction` instances (or + ``None`` for separators). + + Returns: + List of actions and separators + """ + return [ + None, + self.openh5_action, + self.saveh5_action, + self.browseh5_action, + ] + + def _update_file_menu(self) -> None: + """Update file menu before showing up. + + Updates action states (via :meth:`_is_save_enabled`) and populates + the menu with actions returned by :meth:`_get_file_menu_actions`. + + Override in subclasses to add extra logic (e.g., appending a + submenu). Call ``super()._update_file_menu()`` first to populate + the default actions. + """ + self.saveh5_action.setEnabled(self._is_save_enabled()) + add_actions(self.file_menu, self._get_file_menu_actions()) + if self.quit_action is not None: + add_actions(self.file_menu, [self.quit_action]) + + def _get_view_menu_actions(self) -> list[QW.QAction | None]: + """Return the list of actions for the view menu. + + Override in subclasses to customize which actions appear. + + Returns: + List of actions and separators + """ + return [None] + self.createPopupMenu().actions() + + def _update_view_menu(self) -> None: + """Update view menu before showing up. + + Override in subclasses to add extra logic. Call + ``super()._update_view_menu()`` first to populate the default + actions. + """ + add_actions(self.view_menu, self._get_view_menu_actions()) + + def _get_help_doc_actions(self) -> list[QW.QAction | None]: + """Return the documentation actions of the help menu. + + Override in subclasses to append application-specific entries such as + a tour or a demo. + + Returns: + List of actions and separators + """ + actions: list[QW.QAction | None] = [ + create_action( + self, + _("Online documentation"), + icon=get_icon("libre-gui-help.svg"), + triggered=lambda: webbrowser.open(get_conf().app_docurl.get()), + ), + ] + localdocpath = self.__get_local_doc_path() + if localdocpath is not None: + actions.append( + create_action( + self, + _("PDF documentation"), + icon=get_icon("help_pdf.svg"), + triggered=lambda: webbrowser.open(localdocpath), + ), + ) + return actions + + def _get_help_support_actions(self) -> list[QW.QAction | None]: + """Return the troubleshooting actions of the help menu. + + Override in subclasses to append application-specific entries such as + an installation and configuration viewer. + + Returns: + List of actions and separators + """ + actions: list[QW.QAction | None] = [] + if TEST_SEGFAULT_ERROR: + actions.append( + create_action( + self, + _("Test segfault/Python error"), + triggered=self.test_segfault_error, + ) + ) + actions.append( + create_action( + self, + _("Log files") + "...", + icon=get_icon("logs.svg"), + triggered=self._show_logviewer, + ) + ) + return actions + + def _get_help_about_actions(self) -> list[QW.QAction | None]: + """Return the project and about actions of the help menu. + + Returns: + List of actions and separators + """ + return [ + None, + create_action( + self, + _("Project home page"), + icon=get_icon("libre-gui-globe.svg"), + triggered=lambda: webbrowser.open(get_conf().app_homeurl.get()), + ), + create_action( + self, + _("Bug report or feature request"), + icon=get_icon("libre-gui-globe.svg"), + triggered=lambda: webbrowser.open(get_conf().app_supporturl.get()), + ), + create_action( + self, + _("About..."), + icon=get_icon("libre-gui-about.svg"), + triggered=self._about, + ), + ] + + def _get_help_menu_actions(self) -> list[QW.QAction | None]: + """Return the list of actions for the help menu. + + Override in subclasses to wrap the standard groups, which are returned + by :meth:`_get_help_doc_actions`, :meth:`_get_help_support_actions` and + :meth:`_get_help_about_actions`. + + Returns: + List of actions and separators + """ + return ( + self._get_help_doc_actions() + + self._get_help_support_actions() + + self._get_help_about_actions() + ) + + @staticmethod + def _check_h5file(filename: str, operation: str) -> str: + """Check HDF5 filename""" + filename = osp.abspath(osp.normpath(filename)) + bname = osp.basename(filename) + if operation == "load" and not osp.isfile(filename): + raise IOError(f'File not found "{bname}"') + get_conf().base_dir.set(filename) + return filename + + def _has_h5_workspace_persistence(self) -> bool: + """Return whether the derived window implements workspace persistence.""" + return type(self).save_h5_workspace is not SGMXMainWindow.save_h5_workspace + + def save_to_h5_file(self, filename=None) -> None: + """Save to a HDF5 file + + Args: + filename: HDF5 filename. If None, a file dialog is opened. + + Raises: + IOError: if filename is invalid or file cannot be saved. + """ + if filename is None: + basedir = get_conf().base_dir.get() + with qth.save_restore_stds(): + filename, _fl = getsavefilename( + self, + _("Save"), + basedir, + "HDF5 (*.h5 *.hdf5 *.hdf *.he5);;All files (*)", + ) + if not filename: + return + with qth.qt_try_loadsave_file(self, filename, "save"): + self.save_h5_workspace(filename) + + def open_h5_files( + self, + h5files: list[str] | None = None, + import_all: bool | None = None, + reset_all: bool | None = None, + ) -> None: + """Open/import HDF5 files. + + Args: + h5files: HDF5 filenames (optionally with dataset name, separated by ",") + import_all: Import all datasets from HDF5 files + reset_all: Reset all application data before importing + """ + if not self.confirm_memory_state(): + return + conf = get_conf() + if reset_all is None: + # When workspace is empty, always preserve UUIDs (reset_all=True) + # since there's no risk of conflicts + if self._is_workspace_empty(): + reset_all = True + else: + reset_all = conf.h5_clear_workspace.get() + if conf.h5_clear_workspace_ask.get(): + answer = QW.QMessageBox.question( + self, + _("Warning"), + self._get_clear_workspace_message(import_all, reset_all), + QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Ignore, + ) + if answer == QW.QMessageBox.Yes: + reset_all = True + elif answer == QW.QMessageBox.No: + reset_all = False + elif answer == QW.QMessageBox.Ignore: + conf.h5_clear_workspace_ask.set(False) + if h5files is None: + basedir = conf.base_dir.get() + with qth.save_restore_stds(): + h5files, _fl = getopenfilenames( + self, + _("Open"), + basedir, + _("HDF5 files (*.h5 *.hdf5 *.hdf *.he5);;All files (*)"), + ) + if not h5files: + return + filenames, dsetnames = [], [] + for fname_with_dset in h5files: + if "," in fname_with_dset: + filename, dsetname = fname_with_dset.split(",") + dsetnames.append(dsetname) + else: + filename = fname_with_dset + dsetnames.append(None) + filenames.append(filename) + if import_all is None and all(dsetname is None for dsetname in dsetnames): + self.browse_h5_files(filenames, reset_all) + return + for filename, dsetname in zip(filenames, dsetnames): + if import_all is None and dsetname is None: + self.import_all_from_h5_file(filename, reset_all) + else: + with qth.qt_try_loadsave_file(self, filename, "load"): + filename = self._check_h5file(filename, "load") + self.import_dataset_from_file( + filename, dsetname, import_all, reset_all + ) + reset_all = False + + def _is_workspace_empty(self) -> bool: + """Return whether the application workspace holds no object. + + When the workspace is empty, importing cannot cause any identifier + conflict, so the user is not asked whether it should be cleared. The + base window has no data model, so the base implementation returns False. + + Returns: + True if the workspace holds no object + """ + return False + + def _get_clear_workspace_message( + self, import_all: bool | None, reset_all: bool + ) -> str: + """Return the confirmation message shown before clearing the workspace. + + Override in subclasses to use application-specific wording. + + Args: + import_all: Whether all datasets are imported without browsing + reset_all: Current default answer, taken from the configuration + + Returns: + HTML message + """ + msg = _( + "Do you want to clear current workspace " + "before importing data from " + "HDF5 files?" + ) + if import_all: + msg += "

" + _( + "Note: If you choose No, when importing " + "workspace files, objects with conflicting " + "identifiers will have their processing history lost " + "(features like 'Show source' and 'Recompute' will not " + "work for those objects). Non-conflicting objects will " + "preserve their processing history." + ) + msg += "

" + _( + "Choosing to ignore this message will prevent it " + "from being displayed again, and will use the " + "current setting (%s)." + ) % (_("Yes") if reset_all else _("No")) + return msg + + def _handle_imported_objects(self, objects: list, reset_all: bool) -> None: + """Handle the objects imported from an HDF5 file. + + Single convergence point of the import primitives. The base + implementation clears the workspace when requested, then emits + :data:`SIG_SEND_OBJECTLIST`. Override in subclasses to insert the + objects into an application data model directly. + + Args: + objects: Imported native objects + reset_all: Whether the workspace must be cleared beforehand + """ + if not objects: + return + if reset_all: + self.reset_all() + self.SIG_SEND_OBJECTLIST.emit(objects) + self.set_modified(True) + self.statusBar().showMessage( + _("%d object(s) imported successfully") % len(objects), + 5000, + ) + + def import_dataset_from_file( + self, + filename: str, + dsetname: str | None, + import_all: bool | None, + reset_all: bool, + ) -> None: + """Import a specific dataset from an HDF5 file. + + This is a hook for derived applications to handle dataset-specific + import logic. The base implementation is a no-op; subclasses should + override this method to implement their own import strategy. + + 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 + """ + + def browse_h5_files( + self, filenames: list[str], reset_all: bool | None = None + ) -> None: + """Browse HDF5 files + + Opens an :class:`H5BrowserDialog ` + pre-loaded with the given *filenames*, lets the user check datasets to + import, converts the checked nodes into native objects + (:class:`SignalObj ` / + :class:`ImageObj `), and hands them over to + :meth:`_handle_imported_objects`. + + Args: + filenames: HDF5 filenames + reset_all: Reset all application data before importing + """ + for filename in filenames: + self._check_h5file(filename, "load") + + dialog = H5BrowserDialog(self) + dialog.open_files(filenames) + + if exec_dialog(dialog) == QW.QDialog.Accepted: + nodes = dialog.get_nodes() + if not nodes: + dialog.cleanup() + return + objects = [] + for node in nodes: + try: + obj = node.get_native_object() + if obj is not None: + objects.append(obj) + except (OSError, ValueError) as exc: + qt_handle_error_message(self, exc) + dialog.cleanup() + self._handle_imported_objects(objects, bool(reset_all)) + else: + dialog.cleanup() + + def save_h5_workspace(self, filename: str) -> None: + """Save current workspace to an HDF5 file. + + Subclasses that manage a data model must override this method to + perform the actual serialization (e.g. using + :class:`guidata.io.HDF5Writer`). The base implementation never clears + the modified state because it cannot persist an application workspace. + + Args: + filename: HDF5 filename to save to + + Raises: + NotImplementedError: Always, because the base window has no + application workspace to serialize. + """ + del filename + raise NotImplementedError( + "Override save_h5_workspace() to serialize the application workspace." + ) + + def import_all_from_h5_file( + self, filename: str, reset_all: bool | None = None + ) -> None: + """Import every supported dataset of an HDF5 file, without any dialog. + + Uses :class:`H5Importer ` to scan the file, + converts every supported node into a native object + (:class:`SignalObj ` / + :class:`ImageObj `), 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()