├── .github ├── ISSUE_TEMPLATE.md ├── TEST_FAIL_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── ci.yml │ └── docs.yml ├── .github_changelog_generator ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── _gen_widget_pages.py ├── _hooks.py ├── contributing.md ├── getting_started.md ├── images │ ├── PropertyBrowser.png │ ├── all_widgets.png │ ├── basic_usage.mp4 │ ├── favicon.ico │ └── my_widget.mp4 ├── index.md ├── stylesheets │ └── extra.css ├── troubleshooting.md ├── widget_list.json └── widgets │ └── index.md ├── examples ├── basic_usage.py ├── camera_roi_widget.py ├── channel_group_widget.py ├── channel_table.py ├── channel_widget.py ├── config_wizard.py ├── configuration_widget.py ├── core_log_widget.py ├── custom_gui.py ├── default_camera_exposure_widget.py ├── device_widget.py ├── exposure_widget.py ├── grid_plan_widget.py ├── group_preset_table_widget.py ├── hcs_wizard.py ├── image_preview.py ├── install_widget.py ├── live_button.py ├── mda_demo.py ├── mda_sequence_widget.py ├── mda_widget.py ├── objectives_pixel_configuration_widget.py ├── objectives_widget.py ├── pixel_configuration_widget.py ├── points_plan_widget.py ├── position_table.py ├── presets_widget.py ├── properties_widget.py ├── property_browser.py ├── property_widget.py ├── shutters_widget.py ├── snap_button.py ├── stack_viewer.py ├── stage_explorer_widget.py ├── stage_viewer_widget.py ├── stage_widget.py ├── state_device_widget.py ├── temp │ ├── plate_calibration_widget.py │ └── well_calibration_widget.py ├── time_plan_widget.py ├── well_plate_widget.py └── z_plan_widget.py ├── mkdocs.yml ├── pyproject.toml ├── src └── pymmcore_widgets │ ├── __init__.py │ ├── _deprecated │ ├── __init__.py │ └── _device_widget.py │ ├── _icons.py │ ├── _install_widget.py │ ├── _log.py │ ├── _util.py │ ├── config_presets │ ├── __init__.py │ ├── _group_preset_widget │ │ ├── __init__.py │ │ ├── _add_first_preset_widget.py │ │ ├── _add_group_widget.py │ │ ├── _add_preset_widget.py │ │ ├── _cfg_table.py │ │ ├── _edit_group_widget.py │ │ ├── _edit_preset_widget.py │ │ └── _group_preset_table_widget.py │ ├── _objectives_pixel_configuration_widget.py │ └── _pixel_configuration_widget.py │ ├── control │ ├── __init__.py │ ├── _camera_roi_widget.py │ ├── _channel_group_widget.py │ ├── _channel_widget.py │ ├── _exposure_widget.py │ ├── _live_button_widget.py │ ├── _load_system_cfg_widget.py │ ├── _objective_widget.py │ ├── _presets_widget.py │ ├── _q_stage_controller.py │ ├── _shutter_widget.py │ ├── _snap_button_widget.py │ ├── _stage_explorer │ │ ├── __init__.py │ │ ├── _stage_explorer.py │ │ ├── _stage_position_marker.py │ │ ├── _stage_viewer.py │ │ └── auto_zoom_to_fit_icon.svg │ └── _stage_widget.py │ ├── device_properties │ ├── __init__.py │ ├── _device_property_table.py │ ├── _device_type_filter.py │ ├── _properties_widget.py │ ├── _property_browser.py │ └── _property_widget.py │ ├── experimental.py │ ├── hcs │ ├── __init__.py │ ├── _hcs_wizard.py │ ├── _plate_calibration_widget.py │ ├── _util.py │ ├── _well_calibration_widget.py │ └── icons │ │ ├── circle-center.svg │ │ ├── circle-edges.svg │ │ ├── square-center.svg │ │ ├── square-edges.svg │ │ └── square-vertices.svg │ ├── hcwizard │ ├── __init__.py │ ├── _base_page.py │ ├── _dev_setup_dialog.py │ ├── _peripheral_setup_dialog.py │ ├── _simple_prop_table.py │ ├── config_wizard.py │ ├── delay_page.py │ ├── devices_page.py │ ├── finish_page.py │ ├── intro_page.py │ ├── labels_page.py │ └── roles_page.py │ ├── mda │ ├── __init__.py │ ├── _core_channels.py │ ├── _core_grid.py │ ├── _core_mda.py │ ├── _core_positions.py │ ├── _core_z.py │ ├── _save_widget.py │ └── _xy_bounds.py │ ├── py.typed │ ├── useq_widgets │ ├── __init__.py │ ├── _channels.py │ ├── _checkable_tabwidget_widget.py │ ├── _column_info.py │ ├── _data_table.py │ ├── _grid.py │ ├── _mda_sequence.py │ ├── _positions.py │ ├── _time.py │ ├── _well_plate_widget.py │ ├── _z.py │ └── points_plans │ │ ├── __init__.py │ │ ├── _grid_row_column_widget.py │ │ ├── _points_plan_selector.py │ │ ├── _points_plan_widget.py │ │ ├── _random_points_widget.py │ │ └── _well_graphics_view.py │ └── views │ ├── __init__.py │ ├── _image_widget.py │ └── _stack_viewer │ ├── __init__.py │ ├── _channel_row.py │ ├── _datastore.py │ ├── _labeled_slider.py │ ├── _save_button.py │ └── _stack_viewer.py ├── tests ├── conftest.py ├── hcs │ ├── test_hcs_wizard.py │ ├── test_well_calibration_widget.py │ └── test_well_plate_calibration_widget.py ├── test_camera_roi_widget.py ├── test_channel_group_widget.py ├── test_channel_widget.py ├── test_combo_message_box_widget.py ├── test_config.cfg ├── test_config_wizard.py ├── test_core_log_widget.py ├── test_core_state.py ├── test_datastore.py ├── test_device_widget.py ├── test_exposure_widget.py ├── test_group_preset_widget.py ├── test_image_preview.py ├── test_install_widget.py ├── test_live_button.py ├── test_load_system_cfg_widget.py ├── test_objective_pixel_config_widget.py ├── test_objective_widget.py ├── test_pixel_config_widget.py ├── test_presets_widget.py ├── test_prop_widget.py ├── test_properties_widget.py ├── test_property_browser.py ├── test_save_widget.py ├── test_shutter_widget.py ├── test_snap_button_widget.py ├── test_stack_viewer.py ├── test_stage_explorer.py ├── test_stage_widget.py ├── test_useq_core_widgets.py ├── test_utils.py └── useq_widgets │ ├── test_plate_widget.py │ ├── test_useq_points_plans.py │ └── test_useq_widgets.py └── uv.lock /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * pymmcore-widgets version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/TEST_FAIL_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ env.TITLE }}" 3 | labels: [bug] 4 | --- 5 | The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC 6 | 7 | The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} 8 | with commit: {{ sha }} 9 | 10 | Full run: https://github.com/pymmcore-plus/pymmcore-widgets/actions/runs/{{ env.RUN_ID }} 11 | 12 | (This post will be updated if another test fails, as long as this issue remains open.) 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | commit-message: 10 | prefix: "ci(dependabot):" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "v*" 9 | pull_request: {} 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | check-manifest: 18 | name: Check Manifest 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: astral-sh/setup-uv@v6 23 | - run: uvx check-manifest 24 | 25 | test: 26 | name: ${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.backend }} 27 | runs-on: ${{ matrix.os }} 28 | env: 29 | UV_NO_SYNC: "1" 30 | UV_MANAGED_PYTHON: "1" 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | os: [macos-13, windows-latest] 35 | python-version: ["3.9", "3.13"] 36 | backend: [PySide6, PyQt6] 37 | include: 38 | - os: windows-latest 39 | python-version: "3.10" 40 | backend: PySide2 41 | - os: windows-latest 42 | python-version: "3.11" 43 | backend: PySide6 44 | - os: windows-latest 45 | python-version: "3.12" 46 | backend: PyQt6 47 | - os: macos-13 48 | python-version: "3.10" 49 | backend: PySide2 50 | - os: macos-13 51 | python-version: "3.10" 52 | backend: PyQt5 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - uses: astral-sh/setup-uv@v6 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | enable-cache: true 61 | 62 | - uses: pymmcore-plus/setup-mm-test-adapters@main 63 | - uses: pyvista/setup-headless-display-action@v4 64 | 65 | - name: Install dependencies 66 | run: uv sync --no-dev --group test --extra ${{ matrix.backend }} 67 | 68 | - name: Test 69 | run: uv run coverage run -p -m pytest -v --color=yes 70 | 71 | - name: Upload coverage 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: covreport-${{ matrix.os }}-py${{ matrix.python-version }}-${{ matrix.backend }} 75 | path: ./.coverage* 76 | include-hidden-files: true 77 | 78 | upload_coverage: 79 | if: always() 80 | needs: [test] 81 | uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 82 | secrets: 83 | codecov_token: ${{ secrets.CODECOV_TOKEN }} 84 | 85 | test-pymmcore-gui: 86 | name: test pymmcore-gui 87 | runs-on: windows-latest 88 | env: 89 | UV_NO_SYNC: "1" 90 | steps: 91 | - uses: actions/checkout@v4 92 | with: 93 | repository: pymmcore-plus/pymmcore-gui 94 | fetch-depth: 0 95 | 96 | - uses: actions/checkout@v4 97 | with: 98 | path: pymmcore-widgets 99 | fetch-depth: 0 100 | 101 | - uses: astral-sh/setup-uv@v6 102 | with: 103 | enable-cache: true 104 | 105 | - uses: pymmcore-plus/setup-mm-test-adapters@main 106 | - uses: pyvista/setup-headless-display-action@v4 107 | 108 | - name: Install dependencies 109 | run: | 110 | uv sync 111 | uv pip install ./pymmcore-widgets 112 | 113 | - name: Run pymmcore-gui tests 114 | run: uv run pytest -v --color=yes -W ignore 115 | 116 | test-napari-micromanager: 117 | name: test napari-micromanager 118 | runs-on: windows-latest 119 | steps: 120 | - uses: actions/checkout@v4 121 | with: 122 | path: pymmcore-widgets 123 | fetch-depth: 0 124 | 125 | - uses: actions/checkout@v4 126 | with: 127 | repository: pymmcore-plus/napari-micromanager 128 | path: napari-micromanager 129 | fetch-depth: 0 130 | 131 | - uses: actions/setup-python@v5 132 | with: 133 | python-version: "3.11" 134 | 135 | - uses: pymmcore-plus/setup-mm-test-adapters@main 136 | - uses: pyvista/setup-headless-display-action@v4 137 | 138 | - name: Install dependencies 139 | run: | 140 | python -m pip install -U pip 141 | python -m pip install -e ./pymmcore-widgets[PyQt5] 142 | python -m pip install -e ./napari-micromanager[test] 143 | 144 | - name: Run napari-micromanager tests 145 | run: python -m pytest -v --color=yes -W ignore 146 | working-directory: napari-micromanager 147 | 148 | deploy: 149 | name: Deploy 150 | needs: test 151 | if: ${{ github.repository == 'pymmcore-plus/pymmcore-widgets' && contains(github.ref, 'tags') }} 152 | runs-on: ubuntu-latest 153 | permissions: 154 | id-token: write 155 | contents: write 156 | steps: 157 | - uses: actions/checkout@v4 158 | with: 159 | fetch-depth: 0 160 | 161 | - uses: astral-sh/setup-uv@v6 162 | with: 163 | enable-cache: true 164 | 165 | - name: 👷 Build 166 | run: uv build 167 | 168 | - name: 🚢 Publish to PyPI 169 | uses: pypa/gh-action-pypi-publish@release/v1 170 | 171 | - uses: softprops/action-gh-release@v2 172 | with: 173 | generate_release_notes: true 174 | files: "./dist/*" 175 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | 13 | jobs: 14 | deploy: 15 | runs-on: macos-13 16 | env: 17 | UV_NO_SYNC: "1" 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: astral-sh/setup-uv@v6 21 | - uses: pymmcore-plus/setup-mm-test-adapters@main 22 | 23 | - name: Install dependencies 24 | run: uv sync --no-dev --group docs --extra PyQt6 25 | 26 | - name: Test docs 27 | if: github.event_name == 'pull_request' 28 | run: uv run mkdocs build --strict 29 | 30 | - name: Deploy docs 31 | if: github.ref == 'refs/heads/main' 32 | run: uv run mkdocs gh-deploy --strict --force 33 | -------------------------------------------------------------------------------- /.github_changelog_generator: -------------------------------------------------------------------------------- 1 | user=pymmcore-plus 2 | project=pymmcore-widgets 3 | issues=false 4 | exclude-labels=duplicate,question,invalid,wontfix,hide 5 | add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}} 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDE settings 107 | .vscode/ 108 | 109 | pymmcore_widgets/_version.py 110 | src/pymmcore_widgets/_version.py 111 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" 4 | autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" 5 | 6 | repos: 7 | - repo: https://github.com/crate-ci/typos 8 | rev: v1.32.0 9 | hooks: 10 | - id: typos 11 | 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.11.8 14 | hooks: 15 | - id: ruff 16 | args: [--fix, --unsafe-fixes] 17 | - id: ruff-format 18 | 19 | - repo: https://github.com/abravalheri/validate-pyproject 20 | rev: v0.24.1 21 | hooks: 22 | - id: validate-pyproject 23 | 24 | - repo: https://github.com/pre-commit/mirrors-mypy 25 | rev: v1.15.0 26 | hooks: 27 | - id: mypy 28 | files: "^src/" 29 | additional_dependencies: 30 | - pymmcore-plus >=0.14.0 31 | - useq-schema >=0.5.0 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | Copyright (c) 2022, Federico Gasparoli 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymmcore-widgets 2 | 3 | [![License](https://img.shields.io/pypi/l/pymmcore-widgets.svg?color=green)](https://github.com/pymmcore-plus/pymmcore-widgets/raw/main/LICENSE) 4 | [![Python Version](https://img.shields.io/pypi/pyversions/pymmcore-widgets.svg?color=green)](https://python.org) 5 | [![PyPI](https://img.shields.io/pypi/v/pymmcore-widgets.svg?color=green)](https://pypi.org/project/pymmcore-widgets) 6 | [![Conda](https://img.shields.io/conda/vn/conda-forge/pymmcore-widgets)](https://anaconda.org/conda-forge/pymmcore-widgets) 7 | [![CI](https://github.com/pymmcore-plus/pymmcore-widgets/actions/workflows/ci.yml/badge.svg)](https://github.com/pymmcore-plus/pymmcore-widgets/actions/workflows/ci.yml) 8 | [![docs](https://github.com/pymmcore-plus/pymmcore-plus/actions/workflows/docs.yml/badge.svg)](https://pymmcore-plus.github.io/pymmcore-widgets/) 9 | [![codecov](https://codecov.io/gh/pymmcore-plus/pymmcore-widgets/branch/main/graph/badge.svg)](https://codecov.io/gh/pymmcore-plus/pymmcore-widgets) 10 | 11 | A set of widgets for the [pymmcore-plus](https://github.com/pymmcore-plus/pymmcore-plus) package. 12 | This package can be used to build custom user interfaces for micromanager in a python/Qt environment. 13 | It forms the basis of [`napari-micromanager`](https://github.com/pymmcore-plus/napari-micromanager) 14 | 15 | ### [:book: Documentation](https://pymmcore-plus.github.io/pymmcore-widgets) 16 | 17 | mm_widgets 18 | 19 | See complete list of available widgets in the [documentation](https://pymmcore-plus.github.io/pymmcore-widgets/#widgets) 20 | 21 | ## Installation 22 | 23 | ```sh 24 | pip install pymmcore-widgets 25 | 26 | # note that this package does NOT include a Qt backend 27 | # you must install one yourself, for example: 28 | pip install PyQt5 29 | 30 | # package is tested against PyQt5, PyQt6, PySide2, and PySide6(==6.7) 31 | ``` 32 | 33 | ## Development 34 | 35 | Install [uv](https://docs.astral.sh/uv/getting-started/installation/) 36 | 37 | ```sh 38 | git clone 39 | cd pymmcore-widgets 40 | uv sync 41 | ``` 42 | 43 | ### Testing 44 | 45 | ```sh 46 | uv run pytest 47 | ``` 48 | 49 | ### Docs 50 | 51 | ```sh 52 | uv run --group docs mkdocs serve 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/_gen_widget_pages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from pathlib import Path 5 | from textwrap import dedent 6 | 7 | import mkdocs_gen_files 8 | from pymmcore_plus.core import _mmcore_plus 9 | 10 | WIDGET_LIST = Path(__file__).parent / "widget_list.json" 11 | WIDGETS = Path(__file__).parent / "widgets" 12 | EXAMPLES = Path(__file__).parent.parent / "examples" 13 | TEMPLATE = """ 14 |
15 | ![{widget} widget](../{img}){{ loading=lazy, class="widget-image" }} 16 |
17 | This image generated from example code below. 18 |
19 |
20 | 21 | ::: pymmcore_widgets.{widget} 22 | 23 | ## Example 24 | 25 | ```python linenums="1" title="{snake}.py" 26 | --8<-- "examples/{snake}.py" 27 | ``` 28 | """ 29 | 30 | 31 | def _camel_to_snake(name: str) -> str: 32 | import re 33 | 34 | s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) 35 | return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() 36 | 37 | 38 | SEEN: set[int] = set() 39 | 40 | 41 | def _example_screenshot(cls_name: str, dest: str) -> None: 42 | path = EXAMPLES / f"{_camel_to_snake(cls_name)}.py" 43 | if not path.exists(): 44 | raise ValueError(f"Could not find example: {path}") 45 | 46 | from qtpy.QtWidgets import QApplication 47 | 48 | src = path.read_text().strip() 49 | src = src.replace("QApplication([])", "QApplication.instance() or QApplication([])") 50 | src = src.replace("app.exec_()", "") 51 | src = src.replace("app.exec()", "") 52 | gl = {**globals().copy(), "__name__": "__main__"} 53 | exec(src, gl, gl) 54 | 55 | app = QApplication.instance() or QApplication([]) 56 | new = [w for w in app.topLevelWidgets() if id(w) not in SEEN] 57 | # remove all classes that are Qframe or QMenu 58 | new = [w for w in new if w.__class__.__name__ not in ["QFrame", "QMenu"]] 59 | SEEN.update(id(w) for w in new) 60 | if new: 61 | widget = next((w for w in new if w.__class__.__name__ == cls_name), new[0]) 62 | widget.setMinimumWidth(300) # turns out this is very important for grab 63 | widget.grab().save(dest) 64 | 65 | # clean up core instance and application 66 | del _mmcore_plus._instance 67 | _mmcore_plus._instance = None 68 | for w in app.topLevelWidgets(): 69 | w.deleteLater() 70 | 71 | 72 | def _generate_widget_page(widget: str) -> None: 73 | """Auto-Generate pages in the widgets folder.""" 74 | filename = f"widgets/{widget}.md" 75 | snake = _camel_to_snake(widget) 76 | print("Generating", filename) 77 | img = f"images/{snake}.png" 78 | with mkdocs_gen_files.open(img, "wb") as f: 79 | _example_screenshot(widget, f.name) 80 | 81 | with mkdocs_gen_files.open(filename, "w") as f: 82 | f.write(dedent(TEMPLATE.format(widget=widget, snake=snake, img=img))) 83 | 84 | mkdocs_gen_files.set_edit_path(filename, Path(__file__).name) 85 | 86 | 87 | def _generate_widget_pages() -> None: 88 | # it would be nice to do this in parallel, 89 | # but mkdocs_gen_files doesn't work well with multiprocessing 90 | with open(WIDGET_LIST) as f: 91 | widget_dict = json.load(f) 92 | 93 | for _, widgets in widget_dict.items(): 94 | for widget in widgets: 95 | # skip classes that have manual examples 96 | if not (WIDGETS / f"{widget}.md").exists(): 97 | _generate_widget_page(widget) 98 | 99 | 100 | _generate_widget_pages() 101 | -------------------------------------------------------------------------------- /docs/_hooks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Any, cast 4 | 5 | from qtpy.QtWidgets import QWidget 6 | 7 | import pymmcore_widgets 8 | 9 | WIDGET_LIST = Path(__file__).parent / "widget_list.json" 10 | 11 | 12 | def on_page_markdown(md: str, **_: Any) -> str: 13 | """Called when the markdown for a page is loaded.""" 14 | with open(WIDGET_LIST) as f: 15 | widget_dict = json.load(f) 16 | 17 | for section, widget_list in widget_dict.items(): 18 | # e.g. {{ CONFIGURATION_WIDGETS }} in index.md 19 | section_key = "{{ " + cast("str", section).upper() + " }}" 20 | if section_key in md: 21 | md = md.replace(section_key, _widget_table(widget_list)) 22 | return md 23 | 24 | 25 | def _widget_table(widget_list: list[str]) -> str: 26 | table = ["| Widget | Description |", "| ------ | ----------- |"] 27 | for name in dir(pymmcore_widgets): 28 | if name.startswith("_") or name not in widget_list: 29 | continue 30 | obj = getattr(pymmcore_widgets, name) 31 | if isinstance(obj, type) and issubclass(obj, QWidget): 32 | doc = (obj.__doc__ or "").strip().splitlines()[0] 33 | table.append(f"| [{name}]({name}.md) | {doc} |") 34 | 35 | return "\n".join(table) 36 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for thinking of a way to help improve this library! 4 | 5 | Remember that contributions come in all shapes and sizes beyond writing bug fixes. Contributing to documentation, opening new issues for bugs, asking for clarification on things you find unclear or requesting new features, are 6 | all super valuable contributions. 7 | 8 | You can do any of the above by opening a new [issues](https://github.com/pymmcore-plus/pymmcore-widgets/issues) or by submitting a [pull request](https://github.com/pymmcore-plus/pymmcore-widgets/pulls) on the [GitHub repository](https://github.com/pymmcore-plus/pymmcore-widgets) for this project. 9 | -------------------------------------------------------------------------------- /docs/images/PropertyBrowser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/docs/images/PropertyBrowser.png -------------------------------------------------------------------------------- /docs/images/all_widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/docs/images/all_widgets.png -------------------------------------------------------------------------------- /docs/images/basic_usage.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/docs/images/basic_usage.mp4 -------------------------------------------------------------------------------- /docs/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/docs/images/favicon.ico -------------------------------------------------------------------------------- /docs/images/my_widget.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/docs/images/my_widget.mp4 -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | [pymmcore-widgets](https://pypi.org/project/pymmcore-widgets/) 4 | ([github](https://github.com/pymmcore-plus/pymmcore-widgets)) is a library of 5 | [PyQt](https://riverbankcomputing.com/software/pyqt/)/[PySide](https://www.qt.io/qt-for-python) 6 | widgets that can be used in combination with 7 | [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus) 8 | ([github](https://github.com/pymmcore-plus/pymmcore-plus)) to create custom Graphical User 9 | Interfaces (GUIs) for the [Micro-Manager](https://micro-manager.org) software in a pure python/C++ 10 | environment. 11 | 12 | ![all_widgets](./images/all_widgets.png) 13 | 14 | ## Installation 15 | 16 | ```sh 17 | pip install pymmcore-widgets 18 | ``` 19 | 20 | This package does **NOT** include a [PyQt](https://riverbankcomputing.com/software/pyqt/)/[PySide](https://www.qt.io/qt-for-python) backend, you must install one yourself (e.g. ```pip install PyQt6```). 21 | 22 | It also **requires** the `Micro-Manager` device adapters and C++ core provided by [mmCoreAndDevices](https://github.com/micro-manager/mmCoreAndDevices#mmcoreanddevices). 23 | 24 | For a more detailed description on how to install the package, see the [Getting Started](getting_started.md#installation) section. 25 | 26 | ## Usage 27 | 28 | As a quick example, let's create a simple Qt Application that: 29 | 30 | - creates a [Micro-Manager core instance](https://pymmcore-plus.github.io/pymmcore-plus/api/cmmcoreplus/#pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance) so that all the widgets can control the same core. 31 | 32 | - loads the default `Micro-Manager` system configuration. 33 | 34 | - creates and shows a [PropertyBrowser](widgets/PropertyBrowser.md) widget. You can use this widget to view and modify the properties of any of the loaded devices. 35 | 36 | ```py 37 | # import the necessary packages 38 | from qtpy.QtWidgets import QApplication 39 | from pymmcore_plus import CMMCorePlus 40 | from pymmcore_widgets import PropertyBrowser 41 | 42 | # create a QApplication 43 | app = QApplication([]) 44 | 45 | # create a CMMCorePlus instance 46 | mmc = CMMCorePlus.instance() 47 | 48 | # load the default Micro-Manager system configuration. To load a specific 49 | # configuration, provide the "path/to/config.cfg" file as an argument. 50 | mmc.loadSystemConfiguration() 51 | 52 | # create a PropertyBrowser widget. By default, this widget will use the active 53 | # Micro-Manager core instance. 54 | pb_widget = PropertyBrowser() 55 | 56 | # show the created widget 57 | pb_widget.show() 58 | 59 | app.exec_() 60 | ``` 61 | 62 | The code above will create a Qt Application that looks like this: 63 | 64 | ![PropertyBrowser](./images/PropertyBrowser.png) 65 | 66 | A more detailed description on how to use the `pymmcore-widgets` package is explained in the [Getting Started](getting_started.md#usage) section. 67 | 68 | For a pre-made user interface, see [napari-micromanager](https://pypi.org/project/napari-micromanager/) ([github](https://github.com/pymmcore-plus/napari-micromanager)). 69 | 70 | ## Widgets 71 | 72 | For a complete list of widgets offered by this package, see the [Widgets List](widgets). 73 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .md-typeset .admonition, 2 | .md-typeset details { 3 | border-width: 0; 4 | border-left-width: 4px; 5 | } 6 | 7 | .md-typeset .doc-children{ 8 | font-size: 0.7rem; 9 | } 10 | 11 | /* name of a method */ 12 | code.highlight span.n:first-child { 13 | color: red; 14 | } 15 | code.highlight span.n:not(:first-child) { 16 | color: #666; 17 | } 18 | 19 | /* Indentation. */ 20 | div.doc-contents:not(.first) { 21 | padding-left: 25px; 22 | border-left: .05rem solid var(--md-typeset-table-color); 23 | } 24 | 25 | /* Mark external links as such. */ 26 | a.autorefs-external::after { 27 | /* https://primer.style/octicons/arrow-up-right-24 */ 28 | background-image: url('data:image/svg+xml,'); 29 | content: ' '; 30 | 31 | display: inline-block; 32 | position: relative; 33 | top: 0.1em; 34 | margin-left: 0.2em; 35 | margin-right: 0.1em; 36 | 37 | height: 1em; 38 | width: 1em; 39 | border-radius: 100%; 40 | background-color: var(--md-typeset-a-color); 41 | } 42 | a.autorefs-external:hover::after { 43 | background-color: var(--md-accent-fg-color); 44 | } 45 | 46 | .widget-image { 47 | max-height: 600px; 48 | display: block; 49 | margin: auto; 50 | } 51 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## No Qt bindings 4 | 5 | ```sh 6 | qtpy.QtBindingsNotFoundError: No Qt bindings could be found 7 | ``` 8 | 9 | If you get an error similar to the one above, it means that you did not install one of the necessary [PyQt](https://riverbankcomputing.com/software/pyqt/) or [PySide](https://www.qt.io/qt-for-python) libraries (for example, you can run `pip install PyQt6` to install [PyQt6](https://pypi.org/project/PyQt6/)). 10 | 11 | See the [Installing PyQt or PySide](getting_started.md#installing-pyqt-or-pyside) section for more details. 12 | -------------------------------------------------------------------------------- /docs/widget_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "configuration_widgets": [ 3 | "GroupPresetTableWidget", 4 | "InstallWidget", 5 | "PixelConfigurationWidget", 6 | "ObjectivesPixelConfigurationWidget", 7 | "ConfigurationWidget", 8 | "PresetsWidget", 9 | "ConfigWizard" 10 | ], 11 | "device_property_widgets": [ 12 | "PropertyBrowser", 13 | "PropertyWidget", 14 | "PropertiesWidget" 15 | ], 16 | "mda_widgets": [ 17 | "MDAWidget", 18 | "ChannelTable", 19 | "PositionTable", 20 | "TimePlanWidget", 21 | "ZPlanWidget", 22 | "GridPlanWidget", 23 | "MDASequenceWidget" 24 | ], 25 | "camera_widgets": [ 26 | "CameraRoiWidget", 27 | "DefaultCameraExposureWidget", 28 | "ExposureWidget" 29 | ], 30 | "stage_widgets": [ 31 | "StageWidget" 32 | ], 33 | "shutter_widgets": [ 34 | "ShuttersWidget" 35 | ], 36 | "misc_widgets": [ 37 | "ObjectivesWidget", 38 | "ChannelGroupWidget", 39 | "ChannelWidget", 40 | "ImagePreview", 41 | "SnapButton", 42 | "LiveButton", 43 | "CoreLogWidget" 44 | ] 45 | } -------------------------------------------------------------------------------- /docs/widgets/index.md: -------------------------------------------------------------------------------- 1 | # Widgets List 2 | 3 | Below there is a list of all the widgets available in this package **grouped by their functionality**. 4 | 5 | ## Camera Widgets 6 | 7 | The widgets in this section can be used to **control** any `Micro-Manager` device of type [CameraDevice](https://pymmcore-plus.github.io/pymmcore-plus/api/constants/#pymmcore_plus.core._constants.DeviceType.CameraDevice). 8 | 9 | {{ CAMERA_WIDGETS }} 10 | 11 | ## Configuration Widgets 12 | 13 | The widgets in this section can be used to **create, load and modify a Micro-Manager configuration** file. 14 | 15 | {{ CONFIGURATION_WIDGETS }} 16 | 17 | ## Devices and Properties Widgets 18 | 19 | The widgets in this section can be used to **control and intract with the devices and properties** of a `Micro-Manager` core ([CMMCorePlus](https://pymmcore-plus.github.io/pymmcore-plus/api/cmmcoreplus/#cmmcoreplus)). 20 | 21 | {{ DEVICE_PROPERTY_WIDGETS }} 22 | 23 | ## Multi-Dimensional Acquisition Widgets 24 | 25 | The widgets in this section can be used to **define (and run) a multi-dimensional acquisition** based on the [useq-schema MDASequence](https://pymmcore-plus.github.io/useq-schema/schema/sequence/#useq.MDASequence). 26 | 27 | {{ MDA_WIDGETS }} 28 | 29 | ## Shutter Widgets 30 | 31 | The widgets in this section can be used to **control** any `Micro-Manager` [ShutterDevice](https://pymmcore-plus.github.io/pymmcore-plus/api/constants/#pymmcore_plus.core._constants.DeviceType.ShutterDevice). 32 | 33 | {{ SHUTTER_WIDGETS }} 34 | 35 | ## Stage Widgets 36 | 37 | The widgets in this section can be used to **control** any `Micro-Manager` [StageDevice](https://pymmcore-plus.github.io/pymmcore-plus/api/constants/#pymmcore_plus.core._constants.DeviceType.StageDevice). 38 | 39 | {{ STAGE_WIDGETS }} 40 | 41 | ## Misc Widgets 42 | 43 | The widgets in this section are **miscellaneous** widgets that can be used for different purposes. 44 | 45 | {{ MISC_WIDGETS }} 46 | -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | # import the necessary packages 2 | from pymmcore_plus import CMMCorePlus 3 | from qtpy.QtWidgets import QApplication 4 | 5 | from pymmcore_widgets import ConfigurationWidget, GroupPresetTableWidget 6 | 7 | # create a QApplication 8 | app = QApplication([]) 9 | 10 | # create a CMMCorePlus instance. 11 | mmc = CMMCorePlus.instance() 12 | 13 | # create a ConfigurationWidget 14 | cfg_widget = ConfigurationWidget() 15 | 16 | # create a GroupPresetTableWidget 17 | gp_widget = GroupPresetTableWidget() 18 | 19 | # show the created widgets 20 | cfg_widget.show() 21 | gp_widget.show() 22 | 23 | app.exec_() 24 | -------------------------------------------------------------------------------- /examples/camera_roi_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import CameraRoiWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | # this widget supports multiple camera devices 12 | mmc.loadDevice("Camera2", "DemoCamera", "DCam") 13 | mmc.initializeDevice("Camera2") 14 | 15 | cam_roi_wdg = CameraRoiWidget() 16 | cam_roi_wdg.show() 17 | 18 | app.exec() 19 | -------------------------------------------------------------------------------- /examples/channel_group_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import ChannelGroupWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | ch_group_wdg = ChannelGroupWidget() 12 | ch_group_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/channel_table.py: -------------------------------------------------------------------------------- 1 | """Example usage of the ChannelTable class. 2 | 3 | Check also the 'mda_widget.py' example to see the ChannelTable 4 | used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import ChannelTable 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | ch_table_wdg = ChannelTable(rows=1) 18 | ch_table_wdg.setChannelGroups({"Channel": ["DAPI", "FITC"]}) 19 | ch_table_wdg.resize(500, 200) 20 | ch_table_wdg.show() 21 | 22 | app.exec_() 23 | -------------------------------------------------------------------------------- /examples/channel_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the ChannelWidget class. 2 | 3 | Check also the 'image_widget.py' example to see the ChannelWidget 4 | used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import ChannelWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | ch_wdg = ChannelWidget() 18 | ch_wdg.show() 19 | 20 | app.exec_() 21 | -------------------------------------------------------------------------------- /examples/config_wizard.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets.hcwizard.config_wizard import ConfigWizard 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | wiz = ConfigWizard() 12 | wiz.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/configuration_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import ConfigurationWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | cfg_wdg = ConfigurationWidget() 12 | cfg_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/core_log_widget.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from pymmcore_widgets import CoreLogWidget 4 | 5 | app = QApplication([]) 6 | wdg = CoreLogWidget() 7 | wdg.show() 8 | app.exec() 9 | -------------------------------------------------------------------------------- /examples/custom_gui.py: -------------------------------------------------------------------------------- 1 | # Import the necessary packages 2 | from pymmcore_plus import CMMCorePlus 3 | from qtpy.QtWidgets import QGridLayout, QWidget 4 | 5 | from pymmcore_widgets import ( 6 | ChannelGroupWidget, 7 | ChannelWidget, 8 | ConfigurationWidget, 9 | DefaultCameraExposureWidget, 10 | ImagePreview, 11 | LiveButton, 12 | SnapButton, 13 | ) 14 | 15 | 16 | # Create a QWidget class named MyWidget 17 | class MyWidget(QWidget): 18 | """An example QWidget that uses some of the widgets in pymmcore_widgets.""" 19 | 20 | def __init__(self, parent: QWidget | None = None): 21 | super().__init__(parent=parent) 22 | 23 | # This is not strictly necessary but we can create a Micro-Manager core 24 | # instance so that all the widgets can control the same core. If you don't 25 | # create a core instance, the first widget to be instantiated will create 26 | # a new core instance. 27 | CMMCorePlus.instance() 28 | 29 | # Create the wanted pymmcore_widgets 30 | cfg = ConfigurationWidget() 31 | ch_group_combo = ChannelGroupWidget() 32 | ch_combo = ChannelWidget() 33 | exp = DefaultCameraExposureWidget() 34 | preview = ImagePreview() 35 | snap = SnapButton() 36 | live = LiveButton() 37 | 38 | # Create the layout for MyWidget 39 | # In Qt, a `layout` (https://doc.qt.io/qt-6/layout.html) is used to add 40 | # widgets to a `QWidget`. For this example, we'll employ a 41 | # `QGridLayout` (https://doc.qt.io/qt-6/qgridlayout.html) to organize the 42 | # widgets in a grid-like arrangement. 43 | layout = QGridLayout(self) 44 | 45 | # Add the wanted pymmcore_widgets to the layout. 46 | # The first two arguments of 'addWidget' specify the grid position 47 | # in terms of rows and columns. The third and fourth arguments 48 | # define the span of the widget across multiple rows and columns. 49 | layout.addWidget(cfg, 0, 0, 1, 3) 50 | layout.addWidget(ch_group_combo, 1, 0) 51 | layout.addWidget(ch_combo, 1, 1) 52 | layout.addWidget(exp, 1, 2) 53 | layout.addWidget(preview, 2, 0, 1, 3) 54 | layout.addWidget(snap, 3, 1) 55 | layout.addWidget(live, 3, 2) 56 | 57 | 58 | # Create a QApplication and show MyWidget 59 | if __name__ == "__main__": 60 | from qtpy.QtWidgets import QApplication 61 | 62 | app = QApplication([]) 63 | widget = MyWidget() 64 | widget.show() 65 | app.exec_() 66 | -------------------------------------------------------------------------------- /examples/default_camera_exposure_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import DefaultCameraExposureWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | exp_wdg = DefaultCameraExposureWidget() 12 | exp_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/device_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the DeviceWidget class. 2 | 3 | Currently, 'DeviceWidget' only supports devices of type 'StateDevice'. Calling 4 | 'DeviceWidget.for_device("device_label"), will create the 'DeviceWidget' subclass 5 | 'StateDeviceWidget'. 6 | 7 | 'StateDeviceWidget("device_label")' can be directly used to create a 'DeviceWidget' 8 | for a devices of type 'StateDevice' (see also state_device_widget.py). 9 | 10 | In this example all the devices of type 'StateDevice' that are loaded in micromanager 11 | are displayed with a 'DeviceWidget'. 12 | """ 13 | 14 | from pymmcore_plus import CMMCorePlus, DeviceType 15 | from qtpy.QtWidgets import QApplication, QFormLayout, QWidget 16 | 17 | from pymmcore_widgets import DeviceWidget 18 | 19 | app = QApplication([]) 20 | 21 | mmc = CMMCorePlus().instance() 22 | mmc.loadSystemConfiguration() 23 | 24 | wdg = QWidget() 25 | wdg.setLayout(QFormLayout()) 26 | 27 | for d in mmc.getLoadedDevicesOfType(DeviceType.StateDevice): 28 | dev_wdg = DeviceWidget.for_device(d) 29 | wdg.layout().addRow(f"{d}:", dev_wdg) 30 | 31 | wdg.show() 32 | 33 | app.exec_() 34 | -------------------------------------------------------------------------------- /examples/exposure_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import ExposureWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | exp_wdg = ExposureWidget() 12 | exp_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/grid_plan_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the GridPlanWidget class. 2 | 3 | Check also the 'position_table.py' and 'mda_widget.py' examples to see the 4 | GridPlanWidget used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import GridPlanWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | grid_wdg = GridPlanWidget() 18 | grid_wdg.valueChanged.connect(print) 19 | grid_wdg.show() 20 | 21 | app.exec_() 22 | -------------------------------------------------------------------------------- /examples/group_preset_table_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import GroupPresetTableWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | group_preset_wdg = GroupPresetTableWidget() 12 | group_preset_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/hcs_wizard.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | import useq 4 | from pymmcore_plus import CMMCorePlus 5 | from qtpy.QtWidgets import QApplication 6 | 7 | from pymmcore_widgets import StageWidget 8 | 9 | with suppress(ImportError): 10 | from rich import print 11 | 12 | from pymmcore_widgets.hcs import HCSWizard 13 | 14 | app = QApplication([]) 15 | mmc = CMMCorePlus.instance() 16 | mmc.loadSystemConfiguration() 17 | w = HCSWizard() 18 | w.show() 19 | w.accepted.connect(lambda: print(w.value())) 20 | s = StageWidget("XY", mmcore=mmc) 21 | s.show() 22 | 23 | 24 | plan = useq.WellPlatePlan( 25 | plate=useq.WellPlate.from_str("96-well"), 26 | a1_center_xy=(1000, 1500), 27 | rotation=0.3, 28 | selected_wells=slice(0, 8, 2), 29 | ) 30 | w.setValue(plan) 31 | 32 | app.exec() 33 | -------------------------------------------------------------------------------- /examples/image_preview.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QVBoxLayout, QWidget 3 | 4 | from pymmcore_widgets import ( 5 | ChannelWidget, 6 | ExposureWidget, 7 | ImagePreview, 8 | LiveButton, 9 | SnapButton, 10 | ) 11 | 12 | 13 | class ImageFrame(QWidget): 14 | """An example widget with a snap/live button and an image preview.""" 15 | 16 | def __init__(self, parent: QWidget | None = None) -> None: 17 | super().__init__(parent) 18 | 19 | self.preview = ImagePreview() 20 | self.snap_button = SnapButton() 21 | self.live_button = LiveButton() 22 | self.exposure = ExposureWidget() 23 | self.channel = ChannelWidget() 24 | 25 | self.setLayout(QVBoxLayout()) 26 | 27 | buttons = QGroupBox() 28 | buttons.setLayout(QHBoxLayout()) 29 | buttons.layout().addWidget(self.snap_button) 30 | buttons.layout().addWidget(self.live_button) 31 | 32 | ch_exp = QWidget() 33 | layout = QHBoxLayout() 34 | layout.setContentsMargins(0, 0, 0, 0) 35 | ch_exp.setLayout(layout) 36 | 37 | ch = QGroupBox() 38 | ch.setTitle("Channel") 39 | ch.setLayout(QHBoxLayout()) 40 | ch.layout().setContentsMargins(0, 0, 0, 0) 41 | ch.layout().addWidget(self.channel) 42 | layout.addWidget(ch) 43 | 44 | exp = QGroupBox() 45 | exp.setTitle("Exposure") 46 | exp.setLayout(QHBoxLayout()) 47 | exp.layout().setContentsMargins(0, 0, 0, 0) 48 | exp.layout().addWidget(self.exposure) 49 | layout.addWidget(exp) 50 | 51 | self.layout().addWidget(self.preview) 52 | self.layout().addWidget(ch_exp) 53 | self.layout().addWidget(buttons) 54 | 55 | 56 | if __name__ == "__main__": 57 | mmc = CMMCorePlus().instance() 58 | mmc.loadSystemConfiguration() 59 | app = QApplication([]) 60 | frame = ImageFrame() 61 | frame.show() 62 | mmc.snap() 63 | app.exec_() 64 | -------------------------------------------------------------------------------- /examples/install_widget.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | 3 | from pymmcore_widgets import InstallWidget 4 | 5 | app = QApplication([]) 6 | wdg = InstallWidget() 7 | wdg.show() 8 | app.exec() 9 | -------------------------------------------------------------------------------- /examples/live_button.py: -------------------------------------------------------------------------------- 1 | """Example usage of the LiveButton class. 2 | 3 | Check also the 'image_widget.py' example to see the LiveButton 4 | used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import LiveButton 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | live_btn = LiveButton() 18 | live_btn.show() 19 | 20 | app.exec_() 21 | -------------------------------------------------------------------------------- /examples/mda_demo.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pymmcore_plus import CMMCorePlus 3 | from qtpy.QtWidgets import ( 4 | QApplication, 5 | QGroupBox, 6 | QHBoxLayout, 7 | QLabel, 8 | QVBoxLayout, 9 | QWidget, 10 | ) 11 | from useq import MDAEvent 12 | 13 | from pymmcore_widgets import MDAWidget 14 | 15 | 16 | class MDA(QWidget): 17 | """An example of using the MDAWidget to create and acquire a useq.MDASequence. 18 | 19 | The `MDAWidget` provides a GUI to construct a `useq.MDASequence` object. 20 | This object describes a full multi-dimensional acquisition; 21 | In this example, we set the `MDAWidget` parameter `include_run_button` to `True`, 22 | meaning that a `run` button is added to the GUI. When pressed, a `useq.MDASequence` 23 | is first built depending on the GUI values and is then passed to the 24 | `CMMCorePlus.run_mda` to actually execute the acquisition. 25 | For details of the corresponding schema and methods, see 26 | https://github.com/pymmcore-plus/useq-schema and 27 | https://github.com/pymmcore-plus/pymmcore-plus. 28 | In this example, we've also connected callbacks to the CMMCorePlus object's `mda` 29 | events to print out the current state of the acquisition. 30 | """ 31 | 32 | def __init__(self) -> None: 33 | super().__init__() 34 | # get the CMMCore instance and load the default config 35 | self.mmc = CMMCorePlus.instance() 36 | self.mmc.loadSystemConfiguration() 37 | 38 | # connect MDA acquisition events to local callbacks 39 | # in this example we're just printing the current state of the acquisition 40 | self.mmc.mda.events.frameReady.connect(self._on_frame) 41 | self.mmc.mda.events.sequenceFinished.connect(self._on_end) 42 | self.mmc.mda.events.sequencePauseToggled.connect(self._on_pause) 43 | 44 | # instantiate the MDAWidget, and a couple labels for feedback 45 | self.mda = MDAWidget() 46 | self.mda.valueChanged.connect(self._update_sequence) 47 | self.current_sequence = QLabel('... enter info and click "Run"') 48 | self.current_event = QLabel("... current event info will appear here") 49 | 50 | lbl_wdg = QGroupBox() 51 | lbl_layout = QVBoxLayout(lbl_wdg) 52 | lbl_layout.addWidget(QLabel(text="

ACQUISITION SEQUENCE

")) 53 | lbl_layout.addWidget(self.current_sequence) 54 | lbl_layout.addWidget(QLabel(text="

ACQUISITION EVENT

")) 55 | lbl_layout.addWidget(self.current_event) 56 | 57 | layout = QHBoxLayout(self) 58 | layout.addWidget(self.mda) 59 | layout.addWidget(lbl_wdg) 60 | 61 | def _update_sequence(self) -> None: 62 | """Called when the MDA sequence starts.""" 63 | self.current_sequence.setText(self.mda.value().yaml(exclude_defaults=True)) 64 | 65 | def _on_frame(self, image: np.ndarray, event: MDAEvent) -> None: 66 | """Called each time a frame is acquired.""" 67 | self.current_event.setText( 68 | f"index: {event.index}\n" 69 | f"channel: {getattr(event.channel, 'config', 'None')}\n" 70 | f"exposure: {event.exposure}\n" 71 | f"pos_name: {event.pos_name}\n" 72 | f"xyz: ({event.x_pos}, {event.y_pos}, {event.z_pos})\n" 73 | ) 74 | 75 | def _on_end(self) -> None: 76 | """Called when the MDA sequence ends.""" 77 | self.current_event.setText("Finished!") 78 | 79 | def _on_pause(self, state: bool) -> None: 80 | """Called when the MDA is paused.""" 81 | txt = "Paused..." if state else "Resumed!" 82 | self.current_event.setText(txt) 83 | 84 | 85 | if __name__ == "__main__": 86 | app = QApplication([]) 87 | frame = MDA() 88 | frame.show() 89 | app.exec_() 90 | -------------------------------------------------------------------------------- /examples/mda_sequence_widget.py: -------------------------------------------------------------------------------- 1 | """MDASequenceWidget is a widget for creating a useq.MDASequence object. 2 | 3 | It has no awareness of the CMMCorePlus object, and does not have a "run" button. 4 | """ 5 | 6 | import useq 7 | from qtpy.QtWidgets import QApplication 8 | 9 | from pymmcore_widgets import MDASequenceWidget 10 | 11 | app = QApplication([]) 12 | 13 | wdg = MDASequenceWidget() 14 | wdg.channels.setChannelGroups({"Channel": ["DAPI", "FITC"]}) 15 | wdg.time_plan.setValue(useq.TIntervalLoops(interval=0.5, loops=11)) 16 | wdg.valueChanged.connect(lambda: print(wdg.value())) 17 | wdg.show() 18 | app.exec() 19 | -------------------------------------------------------------------------------- /examples/mda_widget.py: -------------------------------------------------------------------------------- 1 | """MDAWidget is a widget for creating and running a useq.MDASequence. 2 | 3 | It is fully connected to the CMMCorePlus object, and has a "run" button. 4 | """ 5 | 6 | from contextlib import suppress 7 | 8 | import useq 9 | from pymmcore_plus import CMMCorePlus 10 | from qtpy.QtWidgets import QApplication 11 | 12 | from pymmcore_widgets import MDAWidget 13 | 14 | with suppress(ImportError): 15 | from rich import print 16 | 17 | app = QApplication([]) 18 | 19 | CMMCorePlus.instance().loadSystemConfiguration() 20 | 21 | wdg = MDAWidget() 22 | wdg.channels.setChannelGroups({"Channel": ["DAPI", "FITC"]}) 23 | wdg.time_plan.setValue(useq.TIntervalLoops(interval=0.5, loops=11)) 24 | wdg.valueChanged.connect(lambda: print(wdg.value())) 25 | wdg.show() 26 | app.exec() 27 | -------------------------------------------------------------------------------- /examples/objectives_pixel_configuration_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import ObjectivesPixelConfigurationWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | px_wdg = ObjectivesPixelConfigurationWidget() 12 | px_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/objectives_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import ObjectivesWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | obj_wdg = ObjectivesWidget() 12 | obj_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/pixel_configuration_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import PixelConfigurationWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | px_wdg = PixelConfigurationWidget() 12 | px_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/points_plan_widget.py: -------------------------------------------------------------------------------- 1 | from qtpy.QtWidgets import QApplication 2 | from useq import RandomPoints 3 | 4 | from pymmcore_widgets.useq_widgets import PointsPlanWidget 5 | 6 | app = QApplication([]) 7 | 8 | points = RandomPoints( 9 | num_points=60, 10 | allow_overlap=False, 11 | fov_width=300, 12 | fov_height=200, 13 | max_width=4000, 14 | max_height=4000, 15 | ) 16 | 17 | fs = PointsPlanWidget(points) 18 | fs.setWellSize(6, 6) 19 | fs.show() 20 | 21 | app.exec() 22 | -------------------------------------------------------------------------------- /examples/position_table.py: -------------------------------------------------------------------------------- 1 | """Example usage of the PositionTable class. 2 | 3 | Check also the 'mda_widget.py' example to see the PositionTable 4 | used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import PositionTable 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | pos_wdg = PositionTable(rows=3) 18 | pos_wdg.resize(570, 200) 19 | pos_wdg.show() 20 | 21 | app.exec_() 22 | -------------------------------------------------------------------------------- /examples/presets_widget.py: -------------------------------------------------------------------------------- 1 | """Example Usage of the PresetsWidget class. 2 | 3 | In this example all the available groups created in micromanager 4 | are displayed with a 'PresetsWidget'. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication, QFormLayout, QWidget 9 | 10 | from pymmcore_widgets import PresetsWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | wdg = QWidget() 18 | wdg.setLayout(QFormLayout()) 19 | 20 | for group in mmc.getAvailableConfigGroups(): 21 | gp_wdg = PresetsWidget(group) 22 | wdg.layout().addRow(f"{group}:", gp_wdg) 23 | 24 | wdg.show() 25 | 26 | app.exec_() 27 | -------------------------------------------------------------------------------- /examples/properties_widget.py: -------------------------------------------------------------------------------- 1 | """The PropertiesWidget is a container for a set of PropertyWidgets. 2 | 3 | It creates widgets for a set of different properties, filtered based on 4 | the arguments to the constructor. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus, PropertyType 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import PropertiesWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | wdg = PropertiesWidget( 18 | # regex pattern to match property names 19 | property_name_pattern="test", 20 | property_type={PropertyType.Float}, 21 | has_limits=True, 22 | ) 23 | 24 | wdg.show() 25 | app.exec_() 26 | -------------------------------------------------------------------------------- /examples/property_browser.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import PropertyBrowser 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | pb_wdg = PropertyBrowser() 12 | pb_wdg.show() 13 | 14 | app.exec_() 15 | -------------------------------------------------------------------------------- /examples/property_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication, QFormLayout, QWidget 3 | 4 | from pymmcore_widgets import PropertyWidget 5 | 6 | app = QApplication([]) 7 | 8 | mmc = CMMCorePlus().instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | wdg = QWidget() 12 | wdg.setLayout(QFormLayout()) 13 | 14 | devs_pros = [ 15 | ("Camera", "AllowMultiROI"), 16 | ("Camera", "Binning"), 17 | ("Camera", "CCDTemperature"), 18 | ] 19 | 20 | for dev, prop in devs_pros: 21 | prop_wdg = PropertyWidget(dev, prop) 22 | wdg.layout().addRow(f"{dev}-{prop}:", prop_wdg) 23 | 24 | wdg.show() 25 | 26 | app.exec_() 27 | -------------------------------------------------------------------------------- /examples/shutters_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the ShuttersWidget class. 2 | 3 | In this example all the devices of type 'Shutter' that are loaded 4 | in micromanager are displayed with a 'ShuttersWidget'. 5 | 6 | The autoshutter checkbox is displayed only with the last shutter device. 7 | """ 8 | 9 | from pymmcore_plus import CMMCorePlus, DeviceType 10 | from qtpy.QtWidgets import QApplication, QHBoxLayout, QWidget 11 | 12 | from pymmcore_widgets import ShuttersWidget 13 | 14 | app = QApplication([]) 15 | 16 | mmc = CMMCorePlus().instance() 17 | mmc.loadSystemConfiguration() 18 | 19 | wdg = QWidget() 20 | wdg.setLayout(QHBoxLayout()) 21 | 22 | shutter_dev_list = list(mmc.getLoadedDevicesOfType(DeviceType.Shutter)) 23 | 24 | for idx, shutter_dev in enumerate(shutter_dev_list): 25 | # bool to display the autoshutter checkbox only with the last shutter 26 | autoshutter = bool(idx >= len(shutter_dev_list) - 1) 27 | shutter = ShuttersWidget(shutter_dev, autoshutter=autoshutter) 28 | shutter.button_text_open = shutter_dev 29 | shutter.button_text_closed = shutter_dev 30 | wdg.layout().addWidget(shutter) 31 | 32 | wdg.show() 33 | 34 | app.exec_() 35 | -------------------------------------------------------------------------------- /examples/snap_button.py: -------------------------------------------------------------------------------- 1 | """Example usage of the SnapButton class. 2 | 3 | Check also the 'image_widget.py' example to see the SnapButton 4 | used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import SnapButton 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | snap_btn = SnapButton() 18 | snap_btn.show() 19 | 20 | app.exec_() 21 | -------------------------------------------------------------------------------- /examples/stack_viewer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | from pymmcore_plus import CMMCorePlus 6 | from qtpy import QtWidgets 7 | from useq import MDASequence 8 | 9 | from pymmcore_widgets.experimental import StackViewer 10 | 11 | size = 1028 12 | 13 | mmcore = CMMCorePlus.instance() 14 | mmcore.loadSystemConfiguration() 15 | 16 | mmcore.setProperty("Camera", "OnCameraCCDXSize", size) 17 | mmcore.setProperty("Camera", "OnCameraCCDYSize", size) 18 | mmcore.setProperty("Camera", "StripeWidth", 0.7) 19 | qapp = QtWidgets.QApplication(sys.argv) 20 | 21 | sequence = MDASequence( 22 | channels=( 23 | # {"config": "DAPI", "exposure": 10}, 24 | # {"config": "FITC", "exposure": 1}, 25 | {"config": "Cy5", "exposure": 1}, 26 | ), 27 | time_plan={"interval": 0.2, "loops": 5}, 28 | grid_plan={"rows": 2, "columns": 2}, 29 | ) 30 | 31 | w = StackViewer(sequence=sequence, mmcore=mmcore, transform=(90, True, False)) 32 | w.show() 33 | 34 | mmcore.run_mda(sequence) 35 | qapp.exec() 36 | -------------------------------------------------------------------------------- /examples/stage_explorer_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication, QHBoxLayout, QSplitter, QVBoxLayout, QWidget 3 | 4 | from pymmcore_widgets import GroupPresetTableWidget, MDAWidget, StageWidget 5 | from pymmcore_widgets.control._stage_explorer._stage_explorer import StageExplorer 6 | 7 | app = QApplication([]) 8 | 9 | mmc = CMMCorePlus.instance() 10 | mmc.loadSystemConfiguration() 11 | 12 | # set camera roi (rectangular helps confirm orientation) 13 | mmc.setROI(0, 0, 400, 600) 14 | 15 | xy = mmc.getXYStageDevice() 16 | if mmc.hasProperty(xy, "Velocity"): 17 | mmc.setProperty(xy, "Velocity", 2) 18 | 19 | explorer = StageExplorer() 20 | 21 | stage_ctrl = StageWidget(mmc.getXYStageDevice()) 22 | stage_ctrl.setStep(512) 23 | stage_ctrl.snap_checkbox.setChecked(True) 24 | 25 | z_ctrl = StageWidget(mmc.getFocusDevice()) 26 | z_ctrl.snap_checkbox.setChecked(True) 27 | 28 | mda_widget = MDAWidget() 29 | 30 | group_wdg = GroupPresetTableWidget() 31 | splitter = QSplitter() 32 | splitter.addWidget(group_wdg) 33 | splitter.addWidget(explorer) 34 | right = QWidget() 35 | rlayout = QVBoxLayout(right) 36 | rtop = QHBoxLayout() 37 | rtop.addWidget(stage_ctrl) 38 | rtop.addWidget(z_ctrl) 39 | rlayout.addLayout(rtop) 40 | rlayout.addWidget(mda_widget) 41 | splitter.addWidget(right) 42 | splitter.show() 43 | 44 | app.exec() 45 | -------------------------------------------------------------------------------- /examples/stage_viewer_widget.py: -------------------------------------------------------------------------------- 1 | # NOTE: run in ipython with `%run examples/stage_viewer_widget.py` 2 | 3 | import numpy as np 4 | from qtpy.QtGui import QMouseEvent 5 | 6 | from pymmcore_widgets.control._stage_explorer._stage_viewer import StageViewer 7 | 8 | img = np.random.randint(0, 255, (256, 256), dtype=np.uint8) 9 | 10 | stage_viewer = StageViewer() 11 | stage_viewer.show() 12 | stage_viewer.add_image(img) 13 | T = np.eye(4) 14 | T[3, :3] = 400, 250, 0 # x, y, z shift 15 | stage_viewer.add_image(img, T.T) 16 | stage_viewer.zoom_to_fit() 17 | 18 | 19 | @stage_viewer.canvas.events.mouse_press.connect 20 | def _on_mouse_press(event: QMouseEvent) -> None: 21 | """Handle the mouse press event.""" 22 | canvas_pos = (event.pos[0], event.pos[1]) 23 | last_image = list(stage_viewer._get_images())[-1] 24 | tform = last_image.transforms.get_transform("canvas", "scene") 25 | world_pos = tform.map(canvas_pos)[:2] 26 | print() 27 | print(world_pos) 28 | -------------------------------------------------------------------------------- /examples/stage_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the StageWidget class. 2 | 3 | In this example all the devices of type 'Stage' and 'XYStage' that are loaded 4 | in micromanager are displayed with a 'StageWidget'. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus, DeviceType 8 | from qtpy.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QWidget 9 | 10 | from pymmcore_widgets import StageWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | wdg = QWidget() 18 | wdg_layout = QHBoxLayout(wdg) 19 | 20 | stages = list(mmc.getLoadedDevicesOfType(DeviceType.XYStage)) 21 | stages.extend(mmc.getLoadedDevicesOfType(DeviceType.Stage)) 22 | for stage in stages: 23 | lbl = "Z" if mmc.getDeviceType(stage) == DeviceType.Stage else "XY" 24 | bx = QGroupBox(f"{lbl} Control") 25 | bx_layout = QHBoxLayout(bx) 26 | bx_layout.setContentsMargins(0, 0, 0, 0) 27 | bx_layout.addWidget(StageWidget(device=stage, position_label_below=True)) 28 | wdg_layout.addWidget(bx) 29 | 30 | 31 | wdg.show() 32 | app.exec() 33 | -------------------------------------------------------------------------------- /examples/state_device_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the StateDeviceWidget class. 2 | 3 | In this example all the devices of type 'StateDevice' that are loaded in micromanager 4 | are displayed with a 'StateDeviceWidget'. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus, DeviceType 8 | from qtpy.QtWidgets import QApplication, QFormLayout, QWidget 9 | 10 | from pymmcore_widgets import StateDeviceWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | wdg = QWidget() 18 | wdg.setLayout(QFormLayout()) 19 | 20 | for d in mmc.getLoadedDevicesOfType(DeviceType.StateDevice): 21 | state_dev_wdg = StateDeviceWidget(d) 22 | wdg.layout().addRow(f"{d}:", state_dev_wdg) 23 | 24 | wdg.show() 25 | 26 | app.exec_() 27 | -------------------------------------------------------------------------------- /examples/temp/plate_calibration_widget.py: -------------------------------------------------------------------------------- 1 | import useq 2 | from pymmcore_plus import CMMCorePlus 3 | from qtpy.QtWidgets import QApplication 4 | 5 | from pymmcore_widgets import StageWidget 6 | from pymmcore_widgets.hcs._plate_calibration_widget import PlateCalibrationWidget 7 | 8 | mmc = CMMCorePlus.instance() 9 | mmc.loadSystemConfiguration() 10 | 11 | app = QApplication([]) 12 | 13 | s = StageWidget("XY") 14 | s.show() 15 | 16 | plan = useq.WellPlatePlan( 17 | plate=useq.WellPlate.from_str("96-well"), 18 | a1_center_xy=(1000, 1500), 19 | rotation=0.3, 20 | ) 21 | 22 | wdg = PlateCalibrationWidget(mmcore=mmc) 23 | wdg.setValue(plan) 24 | wdg.show() 25 | 26 | app.exec() 27 | -------------------------------------------------------------------------------- /examples/temp/well_calibration_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from qtpy.QtWidgets import QApplication 3 | 4 | from pymmcore_widgets import StageWidget 5 | from pymmcore_widgets.hcs._well_calibration_widget import WellCalibrationWidget 6 | 7 | mmc = CMMCorePlus.instance() 8 | mmc.loadSystemConfiguration() 9 | 10 | app = QApplication([]) 11 | 12 | s = StageWidget("XY") 13 | s.show() 14 | c = WellCalibrationWidget(mmcore=mmc) 15 | 16 | 17 | @c.calibrationChanged.connect 18 | def _on_calibration_changed(calibrated: bool) -> None: 19 | if calibrated: 20 | print("Calibration changed! New center:", c.wellCenter()) 21 | 22 | 23 | c.setCircularWell(True) 24 | c.show() 25 | 26 | app.exec() 27 | -------------------------------------------------------------------------------- /examples/time_plan_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the TimePlanWidget class. 2 | 3 | Check also the 'mda_widget.py' example to see the TimePlanWidget 4 | used in combination of other widgets. 5 | """ 6 | 7 | import useq 8 | from pymmcore_plus import CMMCorePlus 9 | from qtpy.QtWidgets import QApplication 10 | 11 | from pymmcore_widgets import TimePlanWidget 12 | 13 | app = QApplication([]) 14 | 15 | mmc = CMMCorePlus().instance() 16 | mmc.loadSystemConfiguration() 17 | 18 | t_wdg = TimePlanWidget() 19 | t_wdg.setValue(useq.TIntervalLoops(interval=3, loops=5)) 20 | t_wdg.show() 21 | 22 | app.exec_() 23 | -------------------------------------------------------------------------------- /examples/well_plate_widget.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | import useq 4 | from qtpy.QtWidgets import QApplication 5 | 6 | from pymmcore_widgets.useq_widgets import WellPlateWidget 7 | 8 | with suppress(ImportError): 9 | from rich import print 10 | 11 | 12 | app = QApplication([]) 13 | 14 | plan = useq.WellPlatePlan( 15 | plate="24-well", a1_center_xy=(0, 0), selected_wells=slice(0, 8, 2) 16 | ) 17 | 18 | ps = WellPlateWidget(plan) 19 | ps.valueChanged.connect(print) 20 | ps.show() 21 | 22 | app.exec() 23 | -------------------------------------------------------------------------------- /examples/z_plan_widget.py: -------------------------------------------------------------------------------- 1 | """Example usage of the ZPlanWidget class. 2 | 3 | Check also the 'mda_widget.py' example to see the ZPlanWidget 4 | used in combination of other widgets. 5 | """ 6 | 7 | from pymmcore_plus import CMMCorePlus 8 | from qtpy.QtWidgets import QApplication 9 | 10 | from pymmcore_widgets import ZPlanWidget 11 | 12 | app = QApplication([]) 13 | 14 | mmc = CMMCorePlus().instance() 15 | mmc.loadSystemConfiguration() 16 | 17 | z_wdg = ZPlanWidget() 18 | z_wdg.show() 19 | 20 | app.exec_() 21 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pymmcore-widgets 2 | site_url: https://pymmcore-plus.github.io/pymmcore-widgets 3 | site_description: Widgets to control micro-manager in python. 4 | repo_name: pymmcore-plus/pymmcore-widgets 5 | repo_url: https://github.com/pymmcore-plus/pymmcore-widgets 6 | edit_uri: edit/main/docs/ 7 | strict: true 8 | 9 | theme: 10 | name: "material" 11 | features: 12 | - content.tabs.link 13 | - content.code.copy 14 | - content.code.annotate 15 | - navigation.instant 16 | - navigation.tabs 17 | icon: 18 | logo: fontawesome/solid/microscope 19 | repo: fontawesome/brands/github 20 | favicon: docs/images/favicon.ico 21 | palette: 22 | # Palette toggle for light mode 23 | - media: "(prefers-color-scheme: light)" 24 | scheme: default 25 | primary: dark blue 26 | accent: dark blue 27 | toggle: 28 | icon: material/lightbulb-outline 29 | name: "Switch to dark mode" 30 | # Palette toggle for dark mode 31 | - media: "(prefers-color-scheme: dark)" 32 | scheme: slate 33 | primary: teal 34 | accent: light green 35 | toggle: 36 | icon: material/lightbulb 37 | name: "Switch to light mode" 38 | 39 | nav: 40 | - pymmcore-plus: /pymmcore-plus/ 41 | - useq-schema: /useq-schema/ 42 | - pymmcore-widgets: 43 | - Overview: index.md 44 | - Getting Started: getting_started.md 45 | - Widgets: widgets/ 46 | - Troubleshooting: troubleshooting.md 47 | - Contributing: contributing.md 48 | - napari-micromanager: /napari-micromanager/ 49 | 50 | markdown_extensions: 51 | - tables 52 | - admonition 53 | - pymdownx.snippets # lets you include code snippets from other files 54 | - pymdownx.highlight 55 | - pymdownx.extra 56 | - attr_list 57 | - md_in_html 58 | - pymdownx.tabbed: 59 | alternate_style: true 60 | - pymdownx.emoji: 61 | emoji_index: !!python/name:material.extensions.emoji.twemoji 62 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 63 | - toc: 64 | permalink: "#" 65 | 66 | watch: 67 | - src/pymmcore_widgets 68 | 69 | hooks: 70 | - docs/_hooks.py 71 | 72 | plugins: 73 | - search 74 | - autorefs 75 | - literate-nav # autegenerate nav from _gen_widgets 76 | - section-index 77 | - gen-files: 78 | scripts: 79 | - docs/_gen_widget_pages.py 80 | - mkdocstrings: 81 | handlers: 82 | python: 83 | import: 84 | - https://docs.python.org/3/objects.inv 85 | - https://numpy.org/doc/stable/objects.inv 86 | - https://pymmcore-plus.github.io/pymmcore-plus/objects.inv 87 | - https://pymmcore-plus.github.io/useq-schema/objects.inv 88 | options: 89 | docstring_style: numpy 90 | show_root_heading: true 91 | show_root_full_path: false 92 | # show_object_full_path: false 93 | # show_root_members_full_path: true 94 | # show_signature: false 95 | show_signature_annotations: true 96 | show_source: false 97 | # show_bases: false 98 | # members_order: alphabetical # alphabetical/source 99 | # docstring_section_style: list # or table/list/spacy 100 | - mkdocs-video: 101 | is_video: true 102 | video_muted: true 103 | video_controls: false 104 | video_autoplay: true 105 | video_loop: true 106 | 107 | extra_css: 108 | - stylesheets/extra.css 109 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """A set of widgets for the pymmcore-plus module.""" 2 | 3 | import warnings 4 | from importlib.metadata import PackageNotFoundError, version 5 | from typing import TYPE_CHECKING 6 | 7 | try: 8 | __version__ = version("pymmcore-widgets") 9 | except PackageNotFoundError: 10 | __version__ = "uninstalled" 11 | 12 | __all__ = [ 13 | "CameraRoiWidget", 14 | "ChannelGroupWidget", 15 | "ChannelTable", 16 | "ChannelWidget", 17 | "ConfigWizard", 18 | "ConfigurationWidget", 19 | "CoreLogWidget", 20 | "DefaultCameraExposureWidget", 21 | "DeviceWidget", 22 | "ExposureWidget", 23 | "GridPlanWidget", 24 | "GroupPresetTableWidget", 25 | "HCSWizard", 26 | "ImagePreview", 27 | "InstallWidget", 28 | "LiveButton", 29 | "MDASequenceWidget", 30 | "MDAWidget", 31 | "ObjectivesPixelConfigurationWidget", 32 | "ObjectivesWidget", 33 | "PixelConfigurationWidget", 34 | "PositionTable", 35 | "PresetsWidget", 36 | "PropertiesWidget", 37 | "PropertyBrowser", 38 | "PropertyWidget", 39 | "ShuttersWidget", 40 | "SnapButton", 41 | "StageExplorer", 42 | "StageWidget", 43 | "StateDeviceWidget", 44 | "TimePlanWidget", 45 | "ZPlanWidget", 46 | ] 47 | 48 | from ._install_widget import InstallWidget 49 | from ._log import CoreLogWidget 50 | from .config_presets import ( 51 | GroupPresetTableWidget, 52 | ObjectivesPixelConfigurationWidget, 53 | PixelConfigurationWidget, 54 | ) 55 | from .control import ( 56 | CameraRoiWidget, 57 | ChannelGroupWidget, 58 | ChannelWidget, 59 | ConfigurationWidget, 60 | DefaultCameraExposureWidget, 61 | ExposureWidget, 62 | LiveButton, 63 | ObjectivesWidget, 64 | PresetsWidget, 65 | ShuttersWidget, 66 | SnapButton, 67 | StageExplorer, 68 | StageWidget, 69 | ) 70 | from .device_properties import PropertiesWidget, PropertyBrowser, PropertyWidget 71 | from .hcs import HCSWizard 72 | from .hcwizard import ConfigWizard 73 | from .mda import MDAWidget 74 | from .useq_widgets import ( 75 | ChannelTable, 76 | GridPlanWidget, 77 | MDASequenceWidget, 78 | PositionTable, 79 | TimePlanWidget, 80 | ZPlanWidget, 81 | ) 82 | from .views import ImagePreview 83 | 84 | if TYPE_CHECKING: 85 | from ._deprecated._device_widget import ( 86 | DeviceWidget, 87 | StateDeviceWidget, 88 | ) 89 | 90 | 91 | def __getattr__(name: str) -> object: 92 | if name == "DeviceWidget": 93 | warnings.warn( 94 | "'DeviceWidget' is deprecated, please seek alternatives.", 95 | DeprecationWarning, 96 | stacklevel=2, 97 | ) 98 | from ._deprecated._device_widget import DeviceWidget 99 | 100 | return DeviceWidget 101 | if name == "StateDeviceWidget": 102 | warnings.warn( 103 | "'StateDeviceWidget' is deprecated, please seek alternatives.", 104 | DeprecationWarning, 105 | stacklevel=2, 106 | ) 107 | from ._deprecated._device_widget import StateDeviceWidget 108 | 109 | return StateDeviceWidget 110 | 111 | if name == "ZStackWidget": 112 | warnings.warn( 113 | "'ZStackWidget' is deprecated, using 'ZPlanWidget' instead.", 114 | DeprecationWarning, 115 | stacklevel=2, 116 | ) 117 | return ZPlanWidget 118 | if name == "GridWidget": 119 | warnings.warn( 120 | "'GridWidget' is deprecated, using 'GridPlanWidget' instead.", 121 | DeprecationWarning, 122 | stacklevel=2, 123 | ) 124 | return GridPlanWidget 125 | if name == "PixelSizeWidget": 126 | warnings.warn( 127 | "PixelSizeWidget is deprecated, " 128 | "using ObjectivesPixelConfigurationWidget instead.", 129 | DeprecationWarning, 130 | stacklevel=2, 131 | ) 132 | return ObjectivesPixelConfigurationWidget 133 | raise AttributeError(f"module {__name__} has no attribute {name}") 134 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/_deprecated/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/src/pymmcore_widgets/_deprecated/__init__.py -------------------------------------------------------------------------------- /src/pymmcore_widgets/_icons.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fonticon_mdi6 import MDI6 4 | from pymmcore_plus import DeviceType 5 | 6 | ICONS: dict[DeviceType, str] = { 7 | DeviceType.Any: MDI6.devices, 8 | DeviceType.AutoFocus: MDI6.auto_upload, 9 | DeviceType.Camera: MDI6.camera, 10 | DeviceType.Core: MDI6.checkbox_blank_circle_outline, 11 | DeviceType.Galvo: MDI6.mirror_variant, 12 | DeviceType.Generic: MDI6.dev_to, 13 | DeviceType.Hub: MDI6.hubspot, 14 | DeviceType.ImageProcessor: MDI6.image_auto_adjust, 15 | DeviceType.Magnifier: MDI6.magnify_plus, 16 | DeviceType.Shutter: MDI6.camera_iris, 17 | DeviceType.SignalIO: MDI6.signal, 18 | DeviceType.SLM: MDI6.view_comfy, 19 | DeviceType.Stage: MDI6.arrow_up_down, 20 | DeviceType.State: MDI6.state_machine, 21 | DeviceType.Unknown: MDI6.dev_to, 22 | DeviceType.XYStage: MDI6.arrow_all, 23 | DeviceType.Serial: MDI6.serial_port, 24 | } 25 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/config_presets/__init__.py: -------------------------------------------------------------------------------- 1 | """Widgets related to configuration groups and presets.""" 2 | 3 | from ._group_preset_widget._group_preset_table_widget import GroupPresetTableWidget 4 | from ._objectives_pixel_configuration_widget import ObjectivesPixelConfigurationWidget 5 | from ._pixel_configuration_widget import PixelConfigurationWidget 6 | 7 | __all__ = [ 8 | "GroupPresetTableWidget", 9 | "ObjectivesPixelConfigurationWidget", 10 | "PixelConfigurationWidget", 11 | ] 12 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/config_presets/_group_preset_widget/__init__.py: -------------------------------------------------------------------------------- 1 | from ._add_first_preset_widget import AddFirstPresetWidget 2 | from ._add_group_widget import AddGroupWidget 3 | from ._add_preset_widget import AddPresetWidget 4 | from ._edit_group_widget import EditGroupWidget 5 | from ._edit_preset_widget import EditPresetWidget 6 | from ._group_preset_table_widget import GroupPresetTableWidget 7 | 8 | __all__ = [ 9 | "AddFirstPresetWidget", 10 | "AddGroupWidget", 11 | "AddPresetWidget", 12 | "EditGroupWidget", 13 | "EditPresetWidget", 14 | "GroupPresetTableWidget", 15 | ] 16 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/config_presets/_group_preset_widget/_add_first_preset_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pymmcore_plus import CMMCorePlus 4 | from qtpy.QtWidgets import ( 5 | QDialog, 6 | QGroupBox, 7 | QHBoxLayout, 8 | QLabel, 9 | QLineEdit, 10 | QPushButton, 11 | QSizePolicy, 12 | QSpacerItem, 13 | QVBoxLayout, 14 | QWidget, 15 | ) 16 | 17 | from pymmcore_widgets._util import block_core 18 | 19 | from ._cfg_table import _CfgTable 20 | 21 | 22 | class AddFirstPresetWidget(QDialog): 23 | """A widget to create the first specified group's preset.""" 24 | 25 | def __init__( 26 | self, 27 | group: str, 28 | dev_prop_val_list: list, 29 | *, 30 | parent: QWidget | None = None, 31 | ) -> None: 32 | super().__init__(parent=parent) 33 | 34 | self._mmc = CMMCorePlus.instance() 35 | self._group = group 36 | self._dev_prop_val_list = dev_prop_val_list 37 | 38 | self._create_gui() 39 | 40 | self.table.populate_table(self._dev_prop_val_list) 41 | 42 | def _create_gui(self) -> None: 43 | self.setWindowTitle(f"Add the first Preset to the new '{self._group}' Group") 44 | 45 | main_layout = QVBoxLayout() 46 | main_layout.setSpacing(0) 47 | main_layout.setContentsMargins(10, 10, 10, 10) 48 | self.setLayout(main_layout) 49 | 50 | wdg = QWidget() 51 | wdg_layout = QVBoxLayout() 52 | wdg_layout.setSpacing(10) 53 | wdg_layout.setContentsMargins(0, 0, 0, 0) 54 | wdg.setLayout(wdg_layout) 55 | 56 | top_wdg = self._create_top_wdg() 57 | wdg_layout.addWidget(top_wdg) 58 | 59 | self.table = _CfgTable() 60 | wdg_layout.addWidget(self.table) 61 | 62 | bottom_wdg = self._create_bottom_wdg() 63 | wdg_layout.addWidget(bottom_wdg) 64 | 65 | main_layout.addWidget(wdg) 66 | 67 | def _create_top_wdg(self) -> QGroupBox: 68 | wdg = QGroupBox() 69 | wdg_layout = QHBoxLayout() 70 | wdg_layout.setSpacing(5) 71 | wdg_layout.setContentsMargins(5, 5, 5, 5) 72 | wdg.setLayout(wdg_layout) 73 | 74 | lbl_sizepolicy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 75 | 76 | gp_lbl = QLabel(text="Group:") 77 | gp_lbl.setSizePolicy(lbl_sizepolicy) 78 | group_name_lbl = QLabel(text=f"{self._group}") 79 | group_name_lbl.setSizePolicy(lbl_sizepolicy) 80 | 81 | ps_lbl = QLabel(text="Preset:") 82 | ps_lbl.setSizePolicy(lbl_sizepolicy) 83 | self.preset_name_lineedit = QLineEdit() 84 | self.preset_name_lineedit.setPlaceholderText(self._get_placeholder_name()) 85 | 86 | spacer = QSpacerItem(30, 10, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 87 | 88 | wdg_layout.addWidget(gp_lbl) 89 | wdg_layout.addWidget(group_name_lbl) 90 | wdg_layout.addItem(spacer) 91 | wdg_layout.addWidget(ps_lbl) 92 | wdg_layout.addWidget(self.preset_name_lineedit) 93 | 94 | return wdg 95 | 96 | def _get_placeholder_name(self) -> str: 97 | idx = sum("NewPreset" in p for p in self._mmc.getAvailableConfigs(self._group)) 98 | return f"NewPreset_{idx}" if idx > 0 else "NewPreset" 99 | 100 | def _create_bottom_wdg(self) -> QWidget: 101 | wdg = QWidget() 102 | wdg_layout = QHBoxLayout() 103 | wdg_layout.setSpacing(5) 104 | wdg_layout.setContentsMargins(0, 0, 0, 0) 105 | wdg.setLayout(wdg_layout) 106 | 107 | self.apply_button = QPushButton(text="Create Preset") 108 | self.apply_button.setSizePolicy( 109 | QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 110 | ) 111 | self.apply_button.clicked.connect(self._create_first_preset) 112 | 113 | spacer = QSpacerItem( 114 | 10, 10, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed 115 | ) 116 | 117 | wdg_layout.addItem(spacer) 118 | wdg_layout.addWidget(self.apply_button) 119 | 120 | return wdg 121 | 122 | def _create_first_preset(self) -> None: 123 | dev_prop_val = self.table.get_state() 124 | preset = self.preset_name_lineedit.text() 125 | if not preset: 126 | preset = self.preset_name_lineedit.placeholderText() 127 | 128 | with block_core(self._mmc.events): 129 | for d, p, v in dev_prop_val: 130 | self._mmc.defineConfig(self._group, preset, d, p, v) 131 | 132 | self._mmc.events.configDefined.emit(self._group, preset, d, p, v) 133 | 134 | self.close() 135 | self.parent().close() 136 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/config_presets/_group_preset_widget/_cfg_table.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, cast 4 | 5 | from qtpy.QtCore import Qt 6 | from qtpy.QtWidgets import QTableWidget, QTableWidgetItem 7 | 8 | from pymmcore_widgets.device_properties._property_widget import PropertyWidget 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | 13 | DEV_PROP_ROLE = Qt.ItemDataRole.UserRole + 1 14 | 15 | 16 | class _CfgTable(QTableWidget): 17 | """Set table properties for EditPresetWidget.""" 18 | 19 | def __init__(self) -> None: 20 | super().__init__() 21 | hdr = self.horizontalHeader() 22 | hdr.setSectionResizeMode(hdr.ResizeMode.Stretch) 23 | hdr.setDefaultAlignment(Qt.AlignmentFlag.AlignHCenter) 24 | vh = self.verticalHeader() 25 | vh.setVisible(False) 26 | vh.setSectionResizeMode(vh.ResizeMode.Fixed) 27 | vh.setDefaultSectionSize(24) 28 | self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) 29 | self.setColumnCount(2) 30 | self.setHorizontalHeaderLabels(["Device-Property", "Value"]) 31 | 32 | def populate_table(self, dev_prop_val: Sequence[Sequence[Any]]) -> None: 33 | self.clearContents() 34 | self.setRowCount(len(dev_prop_val)) 35 | for idx, (dev, prop, *_) in enumerate(dev_prop_val): 36 | item = QTableWidgetItem(f"{dev}-{prop}") 37 | item.setData(DEV_PROP_ROLE, (dev, prop)) 38 | wdg = PropertyWidget(dev, prop, connect_core=False) 39 | self.setItem(idx, 0, item) 40 | self.setCellWidget(idx, 1, wdg) 41 | 42 | def get_state(self) -> list[tuple[str, str, str]]: 43 | dev_prop_val = [] 44 | for row in range(self.rowCount()): 45 | if (dev_prop_item := self.item(row, 0)) and ( 46 | wdg := cast("PropertyWidget", self.cellWidget(row, 1)) 47 | ): 48 | dev, prop = dev_prop_item.data(DEV_PROP_ROLE) 49 | dev_prop_val.append((dev, prop, str(wdg.value()))) 50 | return dev_prop_val 51 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/__init__.py: -------------------------------------------------------------------------------- 1 | """Widgets that control various devices at runtime.""" 2 | 3 | from ._camera_roi_widget import CameraRoiWidget 4 | from ._channel_group_widget import ChannelGroupWidget 5 | from ._channel_widget import ChannelWidget 6 | from ._exposure_widget import DefaultCameraExposureWidget, ExposureWidget 7 | from ._live_button_widget import LiveButton 8 | from ._load_system_cfg_widget import ConfigurationWidget 9 | from ._objective_widget import ObjectivesWidget 10 | from ._presets_widget import PresetsWidget 11 | from ._shutter_widget import ShuttersWidget 12 | from ._snap_button_widget import SnapButton 13 | from ._stage_explorer import StageExplorer 14 | from ._stage_widget import StageWidget 15 | 16 | __all__ = [ 17 | "CameraRoiWidget", 18 | "ChannelGroupWidget", 19 | "ChannelWidget", 20 | "ConfigurationWidget", 21 | "DefaultCameraExposureWidget", 22 | "ExposureWidget", 23 | "LiveButton", 24 | "ObjectivesWidget", 25 | "PresetsWidget", 26 | "ShuttersWidget", 27 | "SnapButton", 28 | "StageExplorer", 29 | "StageWidget", 30 | ] 31 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_channel_group_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pymmcore_plus import CMMCorePlus 4 | from qtpy.QtWidgets import QComboBox, QWidget 5 | from superqt.utils import signals_blocked 6 | 7 | 8 | class ChannelGroupWidget(QComboBox): 9 | """A QComboBox to follow and control Micro-Manager ChannelGroup. 10 | 11 | Parameters 12 | ---------- 13 | parent : QWidget | None 14 | Optional parent widget. By default, None. 15 | mmcore : CMMCorePlus | None 16 | Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. 17 | By default, None. If not specified, the widget will use the active 18 | (or create a new) 19 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | parent: QWidget | None = None, 25 | *, 26 | mmcore: CMMCorePlus | None = None, 27 | ) -> None: 28 | super().__init__(parent) 29 | 30 | self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) 31 | 32 | self._mmc = mmcore or CMMCorePlus.instance() 33 | 34 | self._mmc.events.systemConfigurationLoaded.connect( 35 | self._update_channel_group_combo 36 | ) 37 | self._mmc.events.configGroupDeleted.connect(self._update_channel_group_combo) 38 | self._mmc.events.channelGroupChanged.connect(self._on_channel_group_changed) 39 | self._mmc.events.propertyChanged.connect(self._on_property_changed) 40 | self._mmc.events.configDefined.connect(self._update_channel_group_combo) 41 | 42 | self.textActivated.connect(self._mmc.setChannelGroup) 43 | 44 | self.destroyed.connect(self._disconnect) 45 | 46 | self._update_channel_group_combo() 47 | 48 | def _update_channel_group_combo(self) -> None: 49 | with signals_blocked(self): 50 | self.clear() 51 | self.addItems(self._mmc.getAvailableConfigGroups()) 52 | self.adjustSize() 53 | if ch_group := self._mmc.getChannelGroup(): 54 | self.setCurrentText(ch_group) 55 | self.setStyleSheet("") 56 | else: 57 | self.setStyleSheet("color: magenta;") 58 | 59 | def _on_property_changed(self, device: str, property: str, value: str) -> None: 60 | if device != "Core" or property != "ChannelGroup": 61 | return 62 | with signals_blocked(self): 63 | if value: 64 | self.setCurrentText(value) 65 | self.setStyleSheet("") 66 | else: 67 | self.setStyleSheet("color: magenta;") 68 | 69 | def _on_channel_group_changed(self, group: str) -> None: 70 | if group == self.currentText(): 71 | self.setStyleSheet("") 72 | return 73 | self._update_channel_group_combo() 74 | 75 | def _disconnect(self) -> None: 76 | self._mmc.events.systemConfigurationLoaded.disconnect( 77 | self._update_channel_group_combo 78 | ) 79 | self._mmc.events.channelGroupChanged.disconnect(self._on_channel_group_changed) 80 | self._mmc.events.configGroupDeleted.disconnect(self._update_channel_group_combo) 81 | self._mmc.events.propertyChanged.disconnect(self._on_property_changed) 82 | self._mmc.events.configDefined.disconnect(self._update_channel_group_combo) 83 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_load_system_cfg_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pymmcore_plus import CMMCorePlus 4 | from qtpy.QtWidgets import ( 5 | QFileDialog, 6 | QHBoxLayout, 7 | QLineEdit, 8 | QPushButton, 9 | QWidget, 10 | ) 11 | 12 | from pymmcore_widgets._util import load_system_config 13 | 14 | 15 | class ConfigurationWidget(QWidget): 16 | """A Widget to select and load a micromanager system configuration. 17 | 18 | Parameters 19 | ---------- 20 | parent : QWidget | None 21 | Optional parent widget. By default, None. 22 | mmcore : CMMCorePlus | None 23 | Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. 24 | By default, None. If not specified, the widget will use the active 25 | (or create a new) 26 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 27 | """ 28 | 29 | def __init__( 30 | self, 31 | *, 32 | parent: QWidget | None = None, 33 | mmcore: CMMCorePlus | None = None, 34 | ) -> None: 35 | super().__init__(parent=parent) 36 | 37 | self._mmc = mmcore or CMMCorePlus.instance() 38 | 39 | self.cfg_LineEdit = QLineEdit() 40 | self.cfg_LineEdit.setPlaceholderText("MMConfig_demo.cfg") 41 | 42 | self.browse_cfg_Button = QPushButton("...") 43 | self.browse_cfg_Button.clicked.connect(self._browse_cfg) 44 | 45 | self.load_cfg_Button = QPushButton("Load") 46 | self.load_cfg_Button.clicked.connect(self._load_cfg) 47 | 48 | self.setLayout(QHBoxLayout()) 49 | self.layout().setContentsMargins(0, 0, 0, 0) 50 | self.layout().addWidget(self.cfg_LineEdit) 51 | self.layout().addWidget(self.browse_cfg_Button) 52 | self.layout().addWidget(self.load_cfg_Button) 53 | 54 | def _browse_cfg(self) -> None: 55 | """Open file dialog to select a config file.""" 56 | (filename, _) = QFileDialog.getOpenFileName( 57 | self, "Select a Micro-Manager configuration file", "", "cfg(*.cfg)" 58 | ) 59 | if filename: 60 | self.cfg_LineEdit.setText(filename) 61 | 62 | def _load_cfg(self) -> None: 63 | """Load the config path currently in the line_edit.""" 64 | load_system_config(self.cfg_LineEdit.text(), self._mmc) 65 | 66 | def setTitle(self, title: str) -> None: 67 | _show_deprecation("setTitle") 68 | 69 | def title(self) -> str: 70 | _show_deprecation("title") 71 | return "" 72 | 73 | 74 | def _show_deprecation(name: str) -> None: 75 | import warnings 76 | 77 | warnings.warn( 78 | "ConfigurationWidget is no longer a QGroupBox. " 79 | f"Please place it in a groupbox if you need {name}", 80 | DeprecationWarning, 81 | stacklevel=3, 82 | ) 83 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_q_stage_controller.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar 4 | 5 | from pymmcore_plus import AbstractChangeAccumulator, CMMCorePlus, core 6 | from qtpy.QtCore import QObject, QTimerEvent, Signal 7 | 8 | 9 | class QStageMoveAccumulator(QObject): 10 | """Object to accumulate stage moves and poll for completion. 11 | 12 | This class is meant to be shared by multiple widgets/users that need to share 13 | control of a stage device, possibly accumulating relative moves. 14 | 15 | Create using the `for_device` class method, which will return a cached instance 16 | for the given device and core. 17 | 18 | Attributes 19 | ---------- 20 | moveFinished : Signal 21 | Emitted when the move is finished. This is a signal that can be connected to 22 | other slots to perform actions after the move is completed. 23 | snap_on_finish : bool 24 | If True, a snap will be performed after the move is finished. Prefer using 25 | this to connecting a callback to the `moveFinished` signal, so that multiple 26 | snaps can be avoided if multiple widgets are connected to the signal. 27 | """ 28 | 29 | moveFinished = Signal() 30 | snap_on_finish: bool = False 31 | 32 | @classmethod 33 | def for_device( 34 | cls, device: str, mmcore: CMMCorePlus | None = None 35 | ) -> QStageMoveAccumulator: 36 | """Get a stage controller for the given device.""" 37 | mmcore = mmcore or CMMCorePlus.instance() 38 | key = (id(mmcore), device) 39 | if key not in cls._CACHE: 40 | dev_obj = mmcore.getDeviceObject(device) 41 | if not isinstance(dev_obj, (core.XYStageDevice, core.StageDevice)): 42 | raise TypeError( 43 | f"Cannot {device} is not a stage device. " 44 | f"It is a {dev_obj.type().name!r}." 45 | ) 46 | accum = dev_obj.getPositionAccumulator() 47 | cls._CACHE[key] = QStageMoveAccumulator(accum) 48 | return cls._CACHE[key] 49 | 50 | _CACHE: ClassVar[dict[tuple[int, str], QStageMoveAccumulator]] = {} 51 | 52 | def __init__(self, accumulator: AbstractChangeAccumulator, *, poll_ms: int = 20): 53 | super().__init__() 54 | self._accum = accumulator 55 | self._poll_ms = poll_ms 56 | self._timer_id: int | None = None 57 | # mutable field that may be set by any caller. 58 | # will always be set to False when the move is finished (after snapping) 59 | self.snap_on_finish: bool = False 60 | 61 | def move_relative(self, delta: float | tuple[float, float]) -> None: 62 | """Move the stage relative to its current position.""" 63 | self._accum.add_relative(delta) 64 | if self._timer_id is None: 65 | self._timer_id = self.startTimer(self._poll_ms) 66 | 67 | def move_absolute(self, target: float | tuple[float, float]) -> None: 68 | """Move the stage to an absolute position.""" 69 | self._accum.set_absolute(target) 70 | if self._timer_id is None: 71 | self._timer_id = self.startTimer(self._poll_ms) 72 | 73 | def timerEvent(self, event: QTimerEvent | None) -> None: 74 | if self._accum.poll_done() is True: 75 | if self._timer_id is not None: 76 | self.killTimer(self._timer_id) 77 | self._timer_id = None 78 | 79 | if self.snap_on_finish: 80 | core = getattr(self._accum, "_mmcore", None) 81 | if not isinstance(core, CMMCorePlus): 82 | core = CMMCorePlus.instance() 83 | core.snapImage() 84 | self.snap_on_finish = False 85 | 86 | self.moveFinished.emit() 87 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_snap_button_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | 5 | from fonticon_mdi6 import MDI6 6 | from pymmcore_plus import CMMCorePlus 7 | from qtpy.QtCore import QSize, Qt 8 | from qtpy.QtGui import QColor 9 | from qtpy.QtWidgets import QPushButton, QSizePolicy, QWidget 10 | from superqt.fonticon import icon 11 | from superqt.utils import create_worker 12 | 13 | COLOR_TYPES = Union[ 14 | QColor, 15 | int, 16 | str, 17 | Qt.GlobalColor, 18 | "tuple[int, int, int, int]", 19 | "tuple[int, int, int]", 20 | ] 21 | 22 | 23 | class SnapButton(QPushButton): 24 | """Create a snap QPushButton. 25 | 26 | This button is linked to the 27 | [`CMMCorePlus.snap`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.snap] method. 28 | Once the button is clicked, an image is acquired and the `pymmcore-plus` 29 | signal [`imageSnapped`]() is emitted. 30 | 31 | Parameters 32 | ---------- 33 | parent : QWidget | None 34 | Optional parent widget. 35 | mmcore : CMMCorePlus | None 36 | Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. 37 | By default, None. If not specified, the widget will use the active 38 | (or create a new) 39 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 40 | 41 | Examples 42 | -------- 43 | !!! example "Combining `SnapButton` with other widgets" 44 | 45 | see [ImagePreview](ImagePreview.md#example) 46 | """ 47 | 48 | def __init__( 49 | self, 50 | *, 51 | parent: QWidget | None = None, 52 | mmcore: CMMCorePlus | None = None, 53 | ) -> None: 54 | super().__init__(parent=parent) 55 | 56 | self.setSizePolicy( 57 | QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) 58 | ) 59 | 60 | self._mmc = mmcore or CMMCorePlus.instance() 61 | 62 | self._mmc.events.systemConfigurationLoaded.connect(self._on_system_cfg_loaded) 63 | self._on_system_cfg_loaded() 64 | self.destroyed.connect(self._disconnect) 65 | 66 | self._create_button() 67 | 68 | self.setEnabled(False) 69 | if len(self._mmc.getLoadedDevices()) > 1: 70 | self.setEnabled(True) 71 | 72 | def _create_button(self) -> None: 73 | self.setText("Snap") 74 | self.setIcon(icon(MDI6.camera_outline, color=(0, 255, 0))) 75 | self.setIconSize(QSize(30, 30)) 76 | self.clicked.connect(self._snap) 77 | 78 | def _snap(self) -> None: 79 | if self._mmc.isSequenceRunning(): 80 | self._mmc.stopSequenceAcquisition() 81 | 82 | def snap_with_shutter() -> None: 83 | """ 84 | Perform a snap and ensure shutter signals are sent. 85 | 86 | This is necessary as not all shutter devices properly 87 | send signals as they are opened and closed. 88 | """ 89 | autoshutter = self._mmc.getAutoShutter() 90 | if autoshutter: 91 | self._mmc.events.propertyChanged.emit( 92 | self._mmc.getShutterDevice(), "State", True 93 | ) 94 | self._mmc.snap() 95 | if autoshutter: 96 | self._mmc.events.propertyChanged.emit( 97 | self._mmc.getShutterDevice(), "State", False 98 | ) 99 | 100 | create_worker(snap_with_shutter, _start_thread=True) 101 | 102 | def _on_system_cfg_loaded(self) -> None: 103 | self.setEnabled(bool(self._mmc.getCameraDevice())) 104 | 105 | def _disconnect(self) -> None: 106 | self._mmc.events.systemConfigurationLoaded.disconnect( 107 | self._on_system_cfg_loaded 108 | ) 109 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_stage_explorer/__init__.py: -------------------------------------------------------------------------------- 1 | from ._stage_explorer import StageExplorer 2 | 3 | __all__ = ["StageExplorer"] 4 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_stage_explorer/_stage_position_marker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | from vispy.color import Color 5 | from vispy.scene import Compound, MatrixTransform, Node 6 | from vispy.scene.visuals import Markers, Rectangle 7 | 8 | GREEN = "#33CC33" 9 | 10 | 11 | class StagePositionMarker(Compound): 12 | """A vispy CompoundVisual for a stage-position marker (rect + symbol).""" 13 | 14 | def __init__( 15 | self, 16 | parent: Node, 17 | *, 18 | center: tuple[float, float] = (0, 0), 19 | rect_width: int = 50, 20 | rect_height: int = 50, 21 | rect_color: str = GREEN, 22 | rect_thickness: int = 2, 23 | show_rect: bool = True, 24 | marker_symbol: str = "++", 25 | marker_symbol_color: str = GREEN, 26 | marker_symbol_size: float = 10, 27 | marker_symbol_edge_width: float = 2, 28 | show_marker_symbol: bool = True, 29 | ) -> None: 30 | self._rect = Rectangle( 31 | center=center, 32 | width=rect_width, 33 | height=rect_height, 34 | border_width=rect_thickness, 35 | border_color=Color(rect_color), 36 | color=Color("transparent"), 37 | ) 38 | 39 | self._marker = Markers( 40 | pos=np.array([center]), 41 | symbol=marker_symbol, 42 | face_color=Color(marker_symbol_color), 43 | edge_color=Color(marker_symbol_color), 44 | size=marker_symbol_size, 45 | edge_width=marker_symbol_edge_width, 46 | scaling="fixed", 47 | ) 48 | 49 | super().__init__([self._marker, self._rect]) 50 | 51 | self.parent = parent 52 | self._rect.visible = show_rect 53 | self._marker.visible = show_marker_symbol 54 | self.set_gl_state(depth_test=False) 55 | 56 | def set_rect_visible(self, show: bool) -> None: 57 | """Toggle the rectangle border.""" 58 | self._rect.visible = show 59 | 60 | def set_marker_visible(self, show: bool) -> None: 61 | """Toggle the center symbol.""" 62 | self._marker.visible = show 63 | 64 | def apply_transform(self, mat: np.ndarray) -> None: 65 | """Apply a uniform transform to both sub-visuals.""" 66 | self.transform = MatrixTransform(matrix=mat) 67 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/control/_stage_explorer/auto_zoom_to_fit_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 14 | 33 | 38 | 39 | 43 | A 55 | 56 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/device_properties/__init__.py: -------------------------------------------------------------------------------- 1 | """Widgets related to device properties.""" 2 | 3 | from ._properties_widget import PropertiesWidget 4 | from ._property_browser import PropertyBrowser 5 | from ._property_widget import PropertyWidget 6 | 7 | __all__ = ["PropertiesWidget", "PropertyBrowser", "PropertyWidget"] 8 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/device_properties/_device_type_filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from pymmcore_plus import DeviceType 6 | from qtpy.QtCore import Qt, Signal 7 | from qtpy.QtWidgets import ( 8 | QCheckBox, 9 | QGridLayout, 10 | QGroupBox, 11 | QPushButton, 12 | QVBoxLayout, 13 | QWidget, 14 | ) 15 | 16 | DevTypeLabels: dict[str, tuple[DeviceType, ...]] = { 17 | "cameras": (DeviceType.CameraDevice,), 18 | "shutters": (DeviceType.ShutterDevice,), 19 | "stages": (DeviceType.StageDevice,), 20 | "wheels, turrets, etc.": (DeviceType.StateDevice,), 21 | } 22 | _d: set[DeviceType] = set.union(*(set(i) for i in DevTypeLabels.values())) 23 | DevTypeLabels["other devices"] = tuple(set(DeviceType) - _d) 24 | 25 | 26 | class DeviceTypeFilters(QWidget): 27 | filtersChanged = Signal() 28 | 29 | def __init__(self, parent: QWidget | None = None) -> None: 30 | super().__init__(parent=parent) 31 | self._filters: set[DeviceType] = set() 32 | 33 | all_btn = QPushButton("All") 34 | all_btn.clicked.connect(self._check_all) 35 | none_btn = QPushButton("None") 36 | none_btn.clicked.connect(self._check_none) 37 | 38 | grid = QGridLayout() 39 | grid.setSpacing(6) 40 | grid.addWidget(all_btn, 0, 0, 1, 1) 41 | grid.addWidget(none_btn, 0, 1, 1, 1) 42 | for i, (label, devtypes) in enumerate(DevTypeLabels.items()): 43 | cb = QCheckBox(label) 44 | cb.setChecked(devtypes[0] not in self._filters) 45 | cb.toggled.connect(self._toggle_filter) 46 | grid.addWidget(cb, i + 1, 0, 1, 2) 47 | 48 | self._dev_gb = QGroupBox("Device Type") 49 | self._dev_gb.setLayout(grid) 50 | 51 | for x in self._dev_gb.findChildren(QWidget): 52 | cast("QWidget", x).setFocusPolicy(Qt.FocusPolicy.NoFocus) 53 | 54 | self._read_only_checkbox = QCheckBox("Show read-only") 55 | self._read_only_checkbox.setChecked(True) 56 | self._read_only_checkbox.toggled.connect(self.filtersChanged) 57 | self._read_only_checkbox.setFocusPolicy(Qt.FocusPolicy.NoFocus) 58 | 59 | self._pre_init_checkbox = QCheckBox("Show pre-init props") 60 | self._pre_init_checkbox.setChecked(True) 61 | self._pre_init_checkbox.toggled.connect(self.filtersChanged) 62 | self._pre_init_checkbox.setFocusPolicy(Qt.FocusPolicy.NoFocus) 63 | 64 | layout = QVBoxLayout() 65 | layout.addWidget(self._dev_gb) 66 | layout.addWidget(self._read_only_checkbox) 67 | layout.addWidget(self._pre_init_checkbox) 68 | layout.addStretch() 69 | self.setLayout(layout) 70 | 71 | def _check_all(self) -> None: 72 | for cxbx in self._dev_gb.findChildren(QCheckBox): 73 | cast("QCheckBox", cxbx).setChecked(True) 74 | 75 | def _check_none(self) -> None: 76 | for cxbx in self._dev_gb.findChildren(QCheckBox): 77 | cast("QCheckBox", cxbx).setChecked(False) 78 | 79 | def _toggle_filter(self, toggled: bool) -> None: 80 | label = cast("QCheckBox", self.sender()).text() 81 | self._filters.symmetric_difference_update(DevTypeLabels[label]) 82 | self.filtersChanged.emit() 83 | 84 | def filters(self) -> set[DeviceType]: 85 | return self._filters 86 | 87 | def showReadOnly(self) -> bool: 88 | return self._read_only_checkbox.isChecked() # type: ignore 89 | 90 | def setShowReadOnly(self, show: bool) -> None: 91 | self._read_only_checkbox.setChecked(show) 92 | self.filtersChanged.emit() 93 | 94 | def showPreInitProps(self) -> bool: 95 | return self._pre_init_checkbox.isChecked() # type: ignore 96 | 97 | def setShowPreInitProps(self, show: bool) -> None: 98 | self._pre_init_checkbox.setChecked(show) 99 | self.filtersChanged.emit() 100 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/device_properties/_properties_widget.py: -------------------------------------------------------------------------------- 1 | """Whereas PropertyWidget shows a single property, PropertiesWidget is a container. 2 | 3 | It shows a number of properties, filtered by a given set of tags. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from typing import TYPE_CHECKING, cast 9 | 10 | from pymmcore_plus import CMMCorePlus 11 | from qtpy.QtWidgets import QGridLayout, QLabel, QWidget 12 | 13 | from ._property_widget import PropertyWidget 14 | 15 | if TYPE_CHECKING: 16 | import re 17 | from collections.abc import Iterable 18 | 19 | 20 | class PropertiesWidget(QWidget): 21 | """Convenience container to control a specific set of PropertyWidgets. 22 | 23 | Properties can be filtered by a number of criteria, which are passed to 24 | [`CMMCorePlus.iterProperties`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.iterProperties]. 25 | 26 | Parameters 27 | ---------- 28 | property_type : int | Sequence[int] | None 29 | PropertyType (or types) to filter by, by default all property types will 30 | be yielded. 31 | property_name_pattern : str | re.Pattern | None 32 | Property name to filter by, by default all property names will be yielded. 33 | May be a compiled regular expression or a string, in which case it will be 34 | compiled with `re.IGNORECASE`. 35 | device_type : DeviceType | None 36 | DeviceType to filter by, by default all device types will be yielded. 37 | device_label : str | None 38 | Device label to filter by, by default all device labels will be yielded. 39 | has_limits : bool | None 40 | If provided, only properties with `hasPropertyLimits` matching this value 41 | will be yielded. 42 | is_read_only : bool | None 43 | If provided, only properties with `isPropertyReadOnly` matching this value 44 | will be yielded. 45 | is_sequenceable : bool | None 46 | If provided only properties with `isPropertySequenceable` matching this 47 | value will be yielded. 48 | """ 49 | 50 | def __init__( 51 | self, 52 | property_type: int | Iterable[int] | None = None, 53 | property_name_pattern: str | re.Pattern | None = None, 54 | *, 55 | device_type: int | Iterable[int] | None = None, 56 | device_label: str | re.Pattern | None = None, 57 | has_limits: bool | None = None, 58 | is_read_only: bool | None = None, 59 | is_sequenceable: bool | None = None, 60 | parent: QWidget | None = None, 61 | mmcore: CMMCorePlus | None = None, 62 | ): 63 | super().__init__(parent=parent) 64 | self.setLayout(QGridLayout()) 65 | 66 | self._mmc = mmcore or CMMCorePlus.instance() 67 | self._mmc.events.systemConfigurationLoaded.connect(self.rebuild) 68 | 69 | self._property_type = property_type 70 | self._property_name_pattern = property_name_pattern 71 | self._device_type = device_type 72 | self._device_label = device_label 73 | self._has_limits = has_limits 74 | self._is_read_only = is_read_only 75 | self._is_sequenceable = is_sequenceable 76 | 77 | self.destroyed.connect(self._disconnect) 78 | self.rebuild() 79 | 80 | def rebuild(self) -> None: 81 | """Rebuild the layout, populating based on current filters.""" 82 | # clear 83 | while self.layout().count(): 84 | self.layout().takeAt(0).widget().deleteLater() 85 | 86 | # get properties 87 | properties = self._mmc.iterProperties( 88 | property_name_pattern=self._property_name_pattern, 89 | property_type=self._property_type, 90 | device_type=self._device_type, 91 | device_label=self._device_label, 92 | has_limits=self._has_limits, 93 | is_read_only=self._is_read_only, 94 | is_sequenceable=self._is_sequenceable, 95 | as_object=False, 96 | ) 97 | 98 | # create and add widgets 99 | layout = cast("QGridLayout", self.layout()) 100 | for i, (dev, prop) in enumerate(properties): 101 | layout.addWidget(QLabel(f"{dev}::{prop}"), i, 0) 102 | layout.addWidget(PropertyWidget(dev, prop, mmcore=self._mmc), i, 1) 103 | 104 | def _disconnect(self) -> None: 105 | self._mmc.events.systemConfigurationLoaded.disconnect(self.rebuild) 106 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/device_properties/_property_browser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pymmcore_plus import CMMCorePlus 4 | from qtpy.QtWidgets import QDialog, QHBoxLayout, QLineEdit, QVBoxLayout, QWidget 5 | 6 | from ._device_property_table import DevicePropertyTable 7 | from ._device_type_filter import DeviceTypeFilters 8 | 9 | 10 | class PropertyBrowser(QDialog): 11 | """A Widget to browse and change properties of all devices. 12 | 13 | Parameters 14 | ---------- 15 | parent : QWidget | None 16 | Optional parent widget. By default, None. 17 | mmcore : CMMCorePlus | None 18 | Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. 19 | By default, None. If not specified, the widget will use the active 20 | (or create a new) 21 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 22 | """ 23 | 24 | def __init__( 25 | self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None 26 | ): 27 | super().__init__(parent=parent) 28 | self._mmc = mmcore or CMMCorePlus.instance() 29 | 30 | self._prop_table = DevicePropertyTable(mmcore=mmcore) 31 | self._device_filters = DeviceTypeFilters() 32 | self._device_filters.filtersChanged.connect(self._update_filter) 33 | 34 | self._filter_text = QLineEdit() 35 | self._filter_text.setClearButtonEnabled(True) 36 | self._filter_text.setPlaceholderText("Filter by device or property name...") 37 | self._filter_text.textChanged.connect(self._update_filter) 38 | 39 | right = QWidget() 40 | right.setLayout(QVBoxLayout()) 41 | right.layout().addWidget(self._filter_text) 42 | right.layout().addWidget(self._prop_table) 43 | 44 | left = QWidget() 45 | left.setLayout(QVBoxLayout()) 46 | left.layout().addWidget(self._device_filters) 47 | 48 | self.setLayout(QHBoxLayout()) 49 | self.layout().setContentsMargins(6, 12, 12, 12) 50 | self.layout().setSpacing(0) 51 | self.layout().addWidget(left) 52 | self.layout().addWidget(right) 53 | self._mmc.events.systemConfigurationLoaded.connect(self._update_filter) 54 | 55 | self.destroyed.connect(self._disconnect) 56 | 57 | def _disconnect(self) -> None: 58 | self._mmc.events.systemConfigurationLoaded.disconnect(self._update_filter) 59 | 60 | def _update_filter(self) -> None: 61 | filt = self._filter_text.text().lower() 62 | self._prop_table.filterDevices( 63 | filt, 64 | exclude_devices=self._device_filters.filters(), 65 | include_read_only=self._device_filters.showReadOnly(), 66 | include_pre_init=self._device_filters.showPreInitProps(), 67 | ) 68 | 69 | 70 | if __name__ == "__main__": 71 | from qtpy.QtWidgets import QApplication 72 | 73 | CMMCorePlus.instance().loadSystemConfiguration() 74 | app = QApplication([]) 75 | table = PropertyBrowser() 76 | table.show() 77 | 78 | app.exec_() 79 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/experimental.py: -------------------------------------------------------------------------------- 1 | """Experimental widgets.""" 2 | 3 | from .views._stack_viewer import StackViewer 4 | 5 | __all__ = ["StackViewer"] 6 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/__init__.py: -------------------------------------------------------------------------------- 1 | """HCS Wizard.""" 2 | 3 | from ._hcs_wizard import HCSWizard 4 | 5 | __all__ = ["HCSWizard"] 6 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/_util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import numpy as np 6 | 7 | if TYPE_CHECKING: 8 | from collections.abc import Iterable 9 | 10 | 11 | def find_circle_center( 12 | coords: Iterable[tuple[float, float]], 13 | ) -> tuple[float, float, float]: 14 | """Calculate the center of a circle passing through three or more points. 15 | 16 | This function uses the least squares method to find the center of a circle 17 | that passes through the given coordinates. The input coordinates should be 18 | an iterable of 2D points (x, y). 19 | 20 | Returns 21 | ------- 22 | tuple : (x, y, radius) 23 | The center of the circle and the radius of the circle. 24 | """ 25 | points = np.array(coords) 26 | if points.ndim != 2 or points.shape[1] != 2: # pragma: no cover 27 | raise ValueError("Invalid input coordinates") 28 | if len(points) < 3: # pragma: no cover 29 | raise ValueError("At least 3 points are required") 30 | 31 | # Prepare the matrices for least squares 32 | A = np.hstack((points, np.ones((points.shape[0], 1)))) 33 | B = np.sum(points**2, axis=1).reshape(-1, 1) 34 | 35 | # Solve the least squares problem 36 | params, _residuals, rank, s = np.linalg.lstsq(A, B, rcond=None) 37 | 38 | if rank < 3: # pragma: no cover 39 | raise ValueError("The points are collinear or nearly collinear") 40 | 41 | # Extract the circle parameters 42 | x = params[0][0] / 2 43 | y = params[1][0] / 2 44 | 45 | # radius, if needed 46 | r_squared = params[2][0] + x**2 + y**2 47 | radius = np.sqrt(r_squared) 48 | 49 | return (x, y, radius) 50 | 51 | 52 | def find_rectangle_center( 53 | coords: Iterable[tuple[float, float]], 54 | ) -> tuple[float, float, float, float]: 55 | """Find the center of a rectangle/square well from 2 or more points. 56 | 57 | Returns 58 | ------- 59 | tuple : (x, y, width, height) 60 | The center of the rectangle, width, and height. 61 | """ 62 | points = np.array(coords) 63 | 64 | if points.ndim != 2 or points.shape[1] != 2: # pragma: no cover 65 | raise ValueError("Invalid input coordinates") 66 | if len(points) < 2: # pragma: no cover 67 | raise ValueError("At least 2 points are required") 68 | 69 | # Find the min and max x and y values 70 | x_min, y_min = points.min(axis=0) 71 | x_max, y_max = points.max(axis=0) 72 | 73 | # Calculate the center of the rectangle 74 | x = (x_min + x_max) / 2 75 | y = (y_min + y_max) / 2 76 | 77 | # Calculate the width and height of the rectangle 78 | width = x_max - x_min 79 | height = y_max - y_min 80 | return (x, y, width, height) 81 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/icons/circle-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 15 | 21 | 22 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/icons/circle-edges.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 15 | 21 | 27 | 33 | 34 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/icons/square-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 15 | 21 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/icons/square-edges.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 15 | 21 | 27 | 33 | 39 | 40 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcs/icons/square-vertices.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 15 | 21 | 27 | 28 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/__init__.py: -------------------------------------------------------------------------------- 1 | """Configuration wizard creating/editing a MicroManager configuration file.""" 2 | 3 | from .config_wizard import ConfigWizard 4 | 5 | __all__ = ["ConfigWizard"] 6 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/_base_page.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from pymmcore_plus.model import Microscope 3 | from qtpy.QtWidgets import QWizardPage 4 | 5 | 6 | class ConfigWizardPage(QWizardPage): 7 | def __init__(self, model: Microscope, core: CMMCorePlus): 8 | super().__init__() 9 | self._model = model 10 | self._core = core 11 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/_simple_prop_table.py: -------------------------------------------------------------------------------- 1 | """Simple Property Table, with no connections to core like in DevicePropertyBrowser.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | from pymmcore_plus import CMMCorePlus, Keyword 8 | from qtpy.QtCore import Signal 9 | from qtpy.QtWidgets import QComboBox, QTableWidget, QTableWidgetItem, QWidget 10 | 11 | from pymmcore_widgets.device_properties._property_widget import PropertyWidget 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator, Sequence 15 | 16 | 17 | class PortSelector(QComboBox): 18 | """Simple combobox that emits (device_name, library_name) when changed.""" 19 | 20 | portChanged = Signal(str, str) # device_name, library_name 21 | 22 | def __init__( 23 | self, 24 | allowed_values: Sequence[tuple[str | None, str]], 25 | parent: QWidget | None = None, 26 | ): 27 | super().__init__(parent) 28 | for library, device_name in allowed_values: 29 | self.addItem(device_name, library) 30 | self.currentTextChanged.connect(self._on_current_text_changed) 31 | 32 | def _on_current_text_changed(self, text: str) -> None: 33 | self.portChanged.emit(text, self.currentData()) 34 | 35 | def value(self) -> str: 36 | """Implement ValueWidget interface.""" 37 | return self.currentText() # type: ignore 38 | 39 | 40 | class PropTable(QTableWidget): 41 | """Simple Property Table.""" 42 | 43 | portChanged = Signal(str, str) 44 | 45 | def __init__(self, core: CMMCorePlus, parent: QWidget | None = None) -> None: 46 | super().__init__(0, 2, parent) 47 | self._core = core 48 | self.setHorizontalHeaderLabels(["Property", "Value"]) 49 | self.setSizeAdjustPolicy(QTableWidget.SizeAdjustPolicy.AdjustToContents) 50 | self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) 51 | self.setSelectionMode(self.SelectionMode.NoSelection) 52 | self.horizontalHeader().setStretchLastSection(True) 53 | self.verticalHeader().setVisible(False) 54 | self.verticalHeader().setDefaultSectionSize(24) 55 | self.setColumnWidth(0, 200) 56 | 57 | def iterRows(self) -> Iterator[tuple[str, str]]: 58 | """Iterate over rows, yielding (prop_name, prop_value).""" 59 | for r in range(self.rowCount()): 60 | wdg = self.cellWidget(r, 1) 61 | if isinstance(wdg, PortSelector): 62 | yield Keyword.Port, wdg.value() 63 | elif isinstance(wdg, PropertyWidget): 64 | yield self.item(r, 0).text(), wdg.value() 65 | 66 | def rebuild( 67 | self, 68 | device_props: Sequence[tuple[str, str]], 69 | available_com_ports: Sequence[tuple[str, str]] = (), 70 | ) -> None: 71 | """Rebuild the table for the given device and prop_names.""" 72 | self.setRowCount(len(device_props)) 73 | for i, (device, prop_name) in enumerate(device_props): 74 | self.setItem(i, 0, QTableWidgetItem(prop_name)) 75 | if prop_name == Keyword.Port: 76 | # add the current property if it's not already in there 77 | # it might be something like "Undefined" 78 | allow: list[tuple[str | None, str]] = sorted( 79 | available_com_ports, key=lambda x: x[1] 80 | ) 81 | current = self._core.getProperty(device, prop_name) 82 | if not any(x[1] == current for x in allow): 83 | allow = [(None, current), *allow] 84 | wdg = PortSelector(allow) 85 | wdg.setCurrentText(current) 86 | wdg.portChanged.connect(self.portChanged) 87 | else: 88 | wdg = PropertyWidget( 89 | device, prop_name, mmcore=self._core, connect_core=False 90 | ) 91 | self.setCellWidget(i, 1, wdg) 92 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/delay_page.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import webbrowser 4 | from typing import TYPE_CHECKING 5 | 6 | from fonticon_mdi6 import MDI6 7 | from qtpy.QtWidgets import ( 8 | QDoubleSpinBox, 9 | QTableWidget, 10 | QTableWidgetItem, 11 | QToolButton, 12 | QVBoxLayout, 13 | QWidget, 14 | ) 15 | from superqt.fonticon import icon 16 | 17 | from ._base_page import ConfigWizardPage 18 | 19 | if TYPE_CHECKING: 20 | from pymmcore_plus import CMMCorePlus 21 | from pymmcore_plus.model import Device, Microscope 22 | 23 | 24 | class _DelaySpin(QDoubleSpinBox): 25 | def __init__(self) -> None: 26 | super().__init__() 27 | self.setMinimum(0) 28 | self.setMaximum(10000) 29 | self.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons) 30 | 31 | 32 | class DelayTable(QTableWidget): 33 | """Simple Property Table.""" 34 | 35 | def __init__(self, model: Microscope, parent: QWidget | None = None) -> None: 36 | headers = ["", "Label", "Adapter", "Delay [ms]"] 37 | super().__init__(0, len(headers), parent) 38 | self._model = model 39 | self.setHorizontalHeaderLabels(headers) 40 | self.horizontalHeader().setStretchLastSection(True) 41 | self.setSizeAdjustPolicy(QTableWidget.SizeAdjustPolicy.AdjustToContents) 42 | self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) 43 | self.setSelectionMode(self.SelectionMode.NoSelection) 44 | self.verticalHeader().setVisible(False) 45 | self.verticalHeader().setDefaultSectionSize(24) 46 | self.setColumnWidth(0, 200) 47 | 48 | def rebuild(self) -> None: 49 | """Rebuild the table for the given device and prop_names.""" 50 | devs = [d for d in self._model.devices if d.uses_delay] 51 | 52 | self.setRowCount(len(devs)) 53 | for i, dev in enumerate(devs): 54 | btn = QToolButton() 55 | btn.setIcon(icon(MDI6.information_outline, color="blue")) 56 | self.setCellWidget(i, 0, btn) 57 | self.setItem(i, 1, QTableWidgetItem(dev.name)) 58 | self.setItem(i, 2, QTableWidgetItem(dev.adapter_name)) 59 | spin_wdg = _DelaySpin() 60 | spin_wdg.setValue(dev.delay_ms) 61 | self.setCellWidget(i, 3, spin_wdg) 62 | 63 | def _on_click(state: bool, lib: str = dev.library) -> None: 64 | webbrowser.open(f"https://micro-manager.org/{lib}") 65 | 66 | def _on_change(v: float, d: Device = dev) -> None: 67 | d.delay_ms = v 68 | 69 | btn.clicked.connect(_on_click) 70 | spin_wdg.valueChanged.connect(_on_change) 71 | 72 | hh = self.horizontalHeader() 73 | hh.resizeSections(hh.ResizeMode.ResizeToContents) 74 | 75 | 76 | class DelayPage(ConfigWizardPage): 77 | """Page for setting device delays.""" 78 | 79 | def __init__(self, model: Microscope, core: CMMCorePlus): 80 | super().__init__(model, core) 81 | self.setTitle("Set delays for devices without synchronization capabilities") 82 | self.setSubTitle( 83 | "Set how long to wait for the device to act before Micro-Manager will " 84 | "move on (for example, waiting for a shutter to open before an image " 85 | "is snapped). Many devices will determine this automatically. You can click" 86 | "on the info icon for more info on a specific device." 87 | ) 88 | 89 | self.delays_table = DelayTable(self._model) 90 | 91 | layout = QVBoxLayout(self) 92 | layout.addWidget(self.delays_table) 93 | 94 | def initializePage(self) -> None: 95 | """Called to prepare the page just before it is shown.""" 96 | self.delays_table.rebuild() 97 | super().initializePage() 98 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/finish_page.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pymmcore_plus import CMMCorePlus 4 | from pymmcore_plus.model import Microscope 5 | from qtpy.QtWidgets import ( 6 | QFileDialog, 7 | QHBoxLayout, 8 | QLineEdit, 9 | QMessageBox, 10 | QPushButton, 11 | QVBoxLayout, 12 | ) 13 | 14 | from ._base_page import ConfigWizardPage 15 | 16 | DEST_CONFIG = "dest_config" 17 | 18 | 19 | class FinishPage(ConfigWizardPage): 20 | """Page for saving the configuration file.""" 21 | 22 | def __init__(self, model: Microscope, core: CMMCorePlus): 23 | super().__init__(model, core) 24 | self.setTitle("Save configuration and exit") 25 | self.setSubTitle("All done!

Choose where to save your config file.") 26 | 27 | self.file_edit = QLineEdit() 28 | self.file_edit.setPlaceholderText("Select a destination ...") 29 | self.registerField(f"{DEST_CONFIG}*", self.file_edit) 30 | 31 | self.select_file_btn = QPushButton("Browse...") 32 | self.select_file_btn.clicked.connect(self._select_file) 33 | 34 | row_layout = QHBoxLayout() 35 | row_layout.addWidget(self.file_edit) 36 | row_layout.addWidget(self.select_file_btn) 37 | self.file_edit.textChanged.connect(self.completeChanged) 38 | 39 | layout = QVBoxLayout(self) 40 | layout.addLayout(row_layout) 41 | 42 | def initializePage(self) -> None: 43 | """Called to prepare the page just before it is shown.""" 44 | if self._model.config_file: 45 | self.file_edit.setText(self._model.config_file) 46 | self._initial_dest = self.file_edit.text() 47 | 48 | def validatePage(self) -> bool: 49 | """Validate. the page when the user clicks Next or Finish.""" 50 | dest = self.file_edit.text() 51 | if dest == self._initial_dest and Path(dest).exists(): 52 | result = QMessageBox.question( 53 | self, 54 | "File already exists", 55 | f"File {dest} already exists. Overwrite?", 56 | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Yes, 57 | ) 58 | if result == QMessageBox.StandardButton.No: 59 | return False 60 | return True 61 | 62 | def _select_file(self) -> None: 63 | (fname, _) = QFileDialog.getSaveFileName( 64 | self, 65 | "Select Configuration File", 66 | self.file_edit.text(), 67 | "Config Files (*.cfg)", 68 | ) 69 | if fname: 70 | self.file_edit.setText(fname) 71 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/intro_page.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pymmcore_plus import CMMCorePlus 5 | from pymmcore_plus.model import Microscope 6 | from qtpy.QtWidgets import ( 7 | QButtonGroup, 8 | QFileDialog, 9 | QHBoxLayout, 10 | QLineEdit, 11 | QPushButton, 12 | QRadioButton, 13 | QVBoxLayout, 14 | QWidget, 15 | ) 16 | 17 | from ._base_page import ConfigWizardPage 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | SRC_CONFIG = "src_config" 22 | EXISTING_CONFIG = "EXISTING_CONFIG" 23 | 24 | 25 | class IntroPage(ConfigWizardPage): 26 | """First page, for selecting new or existing configuration.""" 27 | 28 | def __init__(self, model: Microscope, core: CMMCorePlus): 29 | super().__init__(model, core) 30 | self.setTitle("Select Configuration File") 31 | self.setSubTitle( 32 | "This wizard will walk you through setting up the hardware in your system." 33 | ) 34 | 35 | self.file_edit = QLineEdit() 36 | self.file_edit.setReadOnly(True) 37 | self.file_edit.setPlaceholderText("Select a configuration file...") 38 | self.registerField(SRC_CONFIG, self.file_edit) 39 | 40 | self.select_file_btn = QPushButton("Browse...") 41 | self.select_file_btn.clicked.connect(self._select_file) 42 | 43 | row = QWidget() 44 | row_layout = QHBoxLayout(row) 45 | row_layout.addWidget(self.file_edit) 46 | row_layout.addWidget(self.select_file_btn) 47 | 48 | self.new_btn = QRadioButton("Create new configuration") 49 | self.new_btn.clicked.connect(lambda: row.setDisabled(True)) 50 | 51 | self.modify_btn = QRadioButton("Modify or explore existing configuration") 52 | self.modify_btn.clicked.connect(lambda: row.setEnabled(True)) 53 | self.registerField(EXISTING_CONFIG, self.modify_btn) 54 | 55 | self.btn_group = QButtonGroup(self) 56 | self.btn_group.addButton(self.new_btn) 57 | self.btn_group.addButton(self.modify_btn) 58 | 59 | self.btn_group.buttonClicked.connect(self.completeChanged) 60 | self.file_edit.textChanged.connect(self.completeChanged) 61 | 62 | layout = QVBoxLayout(self) 63 | layout.addWidget(self.new_btn) 64 | layout.addWidget(self.modify_btn) 65 | layout.addWidget(row) 66 | 67 | def _select_file(self) -> None: 68 | (fname, _) = QFileDialog.getOpenFileName( 69 | self, "Select Configuration File", "", "Config Files (*.cfg)" 70 | ) 71 | if fname: 72 | self.file_edit.setText(fname) 73 | 74 | def initializePage(self) -> None: 75 | """Called to prepare the page just before it is shown.""" 76 | if self.field(SRC_CONFIG): 77 | self.modify_btn.click() 78 | else: 79 | self.new_btn.click() 80 | 81 | def cleanupPage(self) -> None: 82 | """Called to reset the page's contents when the user clicks BACK.""" 83 | self._model.reset() 84 | try: 85 | self._core.unloadAllDevices() 86 | except Exception as e: 87 | logger.exception(e) 88 | 89 | self.file_edit.setText(self._model.config_file) 90 | super().cleanupPage() 91 | 92 | def validatePage(self) -> bool: 93 | """Validate the page when the user clicks Next or Finish.""" 94 | if self.btn_group.checkedButton() is self.new_btn: 95 | self._model.reset() 96 | try: 97 | self._core.unloadAllDevices() 98 | except Exception as e: 99 | logger.exception(e) 100 | else: 101 | self._model.load_config(self.file_edit.text()) 102 | self._model.mark_clean() 103 | return super().validatePage() # type: ignore 104 | 105 | def isComplete(self) -> bool: 106 | """Called to determine whether the Next/Finish button should be enabled.""" 107 | return bool( 108 | self.btn_group.checkedButton() is not self.modify_btn 109 | or os.path.isfile(self.file_edit.text()) 110 | ) 111 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/labels_page.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from pymmcore_plus import CMMCorePlus, DeviceType 4 | from pymmcore_plus.model import Device, Microscope 5 | from qtpy.QtCore import Qt 6 | from qtpy.QtWidgets import ( 7 | QComboBox, 8 | QHBoxLayout, 9 | QLabel, 10 | QTableWidget, 11 | QTableWidgetItem, 12 | QVBoxLayout, 13 | ) 14 | from superqt.utils import signals_blocked 15 | 16 | from ._base_page import ConfigWizardPage 17 | 18 | 19 | class _LabelTable(QTableWidget): 20 | def __init__(self, model: Microscope): 21 | headers = ["State", "Label"] 22 | super().__init__(0, len(headers)) 23 | self._model = model 24 | 25 | self.setSelectionMode(self.SelectionMode.NoSelection) 26 | self.setHorizontalHeaderLabels(headers) 27 | self.horizontalHeader().setStretchLastSection(True) 28 | self.verticalHeader().setVisible(False) 29 | 30 | self.itemChanged.connect(self._on_item_changed) 31 | 32 | def rebuild(self, dev_name: str) -> None: 33 | """Rebuild the table for the given device.""" 34 | if not dev_name: 35 | return 36 | 37 | self.clearContents() 38 | dev = self._model.get_device(dev_name) 39 | self.setRowCount(len(dev.labels)) 40 | for i, label in enumerate(dev.labels): 41 | state = QTableWidgetItem(str(i)) 42 | state.setTextAlignment(Qt.AlignmentFlag.AlignCenter) 43 | state.setFlags(Qt.ItemFlag.ItemIsEnabled) 44 | self.setItem(i, 0, state) 45 | lbl = QTableWidgetItem(label) 46 | lbl.setData(Qt.ItemDataRole.UserRole, dev) 47 | self.setItem(i, 1, lbl) 48 | 49 | def _on_item_changed(self, item: QTableWidgetItem) -> None: 50 | if item.column() != 1: 51 | return 52 | dev = cast("Device", item.data(Qt.ItemDataRole.UserRole)) 53 | dev.set_label(item.row(), item.text()) 54 | 55 | 56 | class LabelsPage(ConfigWizardPage): 57 | """Provide a table for defining position labels for state devices.""" 58 | 59 | def __init__(self, model: Microscope, core: CMMCorePlus): 60 | super().__init__(model, core) 61 | self.setTitle("Define position labels for state devices") 62 | self.setSubTitle( 63 | "Some devices, such as filter wheels and objective turrets, have discrete " 64 | "positions that can have names assigned to them. For example, position 1 " 65 | "of a filter wheel could be the DAPI channel, position 2 the FITC channel, " 66 | "etc.

You may assign names to positions here." 67 | ) 68 | 69 | self.labels_table = _LabelTable(self._model) 70 | 71 | self.dev_combo = QComboBox() 72 | self.dev_combo.currentTextChanged.connect(self.labels_table.rebuild) 73 | 74 | row = QHBoxLayout() 75 | row.addWidget(QLabel("Device:")) 76 | row.addWidget(self.dev_combo, 1) 77 | 78 | layout = QVBoxLayout(self) 79 | layout.addLayout(row) 80 | layout.addWidget(self.labels_table) 81 | 82 | def initializePage(self) -> None: 83 | """Called to prepare the page just before it is shown.""" 84 | with signals_blocked(self.dev_combo): 85 | txt = self.dev_combo.currentText() 86 | self.dev_combo.clear() 87 | items = [ 88 | d.name for d in self._model.devices if d.device_type == DeviceType.State 89 | ] 90 | self.dev_combo.addItems(items) 91 | if txt in items: 92 | self.dev_combo.setCurrentText(txt) 93 | 94 | self.labels_table.rebuild(self.dev_combo.currentText()) 95 | super().initializePage() 96 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/hcwizard/roles_page.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus, DeviceType, Keyword 2 | from pymmcore_plus.model import Microscope 3 | from qtpy.QtWidgets import QCheckBox, QComboBox, QFormLayout 4 | from superqt.utils import signals_blocked 5 | 6 | from ._base_page import ConfigWizardPage 7 | 8 | 9 | class RolesPage(ConfigWizardPage): 10 | """Page for selecting default devices and auto-shutter setting.""" 11 | 12 | def __init__(self, model: Microscope, core: CMMCorePlus): 13 | super().__init__(model, core) 14 | self.setTitle("Select default devices and choose auto-shutter setting") 15 | self.setSubTitle( 16 | "Select the default device to use for certain important roles." 17 | ) 18 | self.camera_combo = QComboBox() 19 | self.camera_combo.currentTextChanged.connect(self._on_camera_changed) 20 | self.shutter_combo = QComboBox() 21 | self.shutter_combo.currentTextChanged.connect(self._on_shutter_changed) 22 | self.focus_combo = QComboBox() 23 | self.focus_combo.currentTextChanged.connect(self._on_focus_changed) 24 | self.auto_shutter_checkbox = QCheckBox() 25 | self.auto_shutter_checkbox.stateChanged.connect(self._on_auto_shutter_changed) 26 | 27 | # TODO: focus directions 28 | layout = QFormLayout(self) 29 | layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) 30 | layout.addRow("Default Camera", self.camera_combo) 31 | layout.addRow("Default Shutter", self.shutter_combo) 32 | layout.addRow("Default Focus Stage", self.focus_combo) 33 | layout.addRow("Use auto-shutter", self.auto_shutter_checkbox) 34 | 35 | def initializePage(self) -> None: 36 | """Called to prepare the page just before it is shown.""" 37 | # try/catch 38 | 39 | # reset and populate the combo boxes with available devices 40 | with signals_blocked(self.camera_combo): 41 | self.camera_combo.clear() 42 | cameras = [ 43 | x.name 44 | for x in self._model.filter_devices(device_type=DeviceType.Camera) 45 | ] 46 | if cameras: 47 | self.camera_combo.addItems(("", *cameras)) 48 | 49 | with signals_blocked(self.shutter_combo): 50 | self.shutter_combo.clear() 51 | shutters = [ 52 | x.name 53 | for x in self._model.filter_devices(device_type=DeviceType.Shutter) 54 | ] 55 | if shutters: 56 | self.shutter_combo.addItems(("", *shutters)) 57 | 58 | with signals_blocked(self.focus_combo): 59 | self.focus_combo.clear() 60 | stages = [ 61 | x.name for x in self._model.filter_devices(device_type=DeviceType.Stage) 62 | ] 63 | if stages: 64 | self.focus_combo.addItems(("", *stages)) 65 | 66 | with signals_blocked(self.auto_shutter_checkbox): 67 | self.auto_shutter_checkbox.setChecked(True) 68 | 69 | # update values from the model 70 | for prop in self._model.core_device.properties: 71 | if prop.name == Keyword.CoreCamera and prop.value: 72 | self.camera_combo.setCurrentText(prop.value) 73 | elif prop.name == Keyword.CoreShutter and prop.value: 74 | self.shutter_combo.setCurrentText(prop.value) 75 | elif prop.name == Keyword.CoreFocus and prop.value: 76 | self.focus_combo.setCurrentText(prop.value) 77 | elif prop.name == Keyword.CoreAutoShutter: 78 | self.auto_shutter_checkbox.setChecked(prop.value == "1") 79 | 80 | if cameras and not self.camera_combo.currentText(): 81 | self.camera_combo.setCurrentText(cameras[0]) 82 | if shutters and not self.shutter_combo.currentText(): 83 | self.shutter_combo.setCurrentText(shutters[0]) 84 | if stages and not self.focus_combo.currentText(): 85 | self.focus_combo.setCurrentText(stages[0]) 86 | 87 | super().initializePage() 88 | 89 | def _on_camera_changed(self, text: str) -> None: 90 | self._model.core_device.set_property(Keyword.CoreCamera, text) 91 | 92 | def _on_shutter_changed(self, text: str) -> None: 93 | self._model.core_device.set_property(Keyword.CoreShutter, text) 94 | 95 | def _on_focus_changed(self, text: str) -> None: 96 | self._model.core_device.set_property(Keyword.CoreFocus, text) 97 | 98 | def _on_auto_shutter_changed(self, state: int) -> None: 99 | val = "1" if bool(state) else "0" 100 | self._model.core_device.set_property(Keyword.CoreAutoShutter, val) 101 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/mda/__init__.py: -------------------------------------------------------------------------------- 1 | """MDA widgets.""" 2 | 3 | from ._core_mda import MDAWidget 4 | 5 | __all__ = ["MDAWidget"] 6 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/mda/_core_channels.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pymmcore_plus import CMMCorePlus 6 | 7 | from pymmcore_widgets.useq_widgets import ChannelTable 8 | 9 | if TYPE_CHECKING: 10 | from qtpy.QtWidgets import QWidget 11 | 12 | DEFAULT_EXP = 100.0 13 | 14 | 15 | class CoreConnectedChannelTable(ChannelTable): 16 | """[ChannelTable](../ChannelTable#) connected to a Micro-Manager core instance. 17 | 18 | Parameters 19 | ---------- 20 | rows : int 21 | Number of rows to initialize the table with, by default 0. 22 | mmcore : CMMCorePlus | None 23 | Optional [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] micromanager core. 24 | By default, None. If not specified, the widget will use the active 25 | (or create a new) 26 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 27 | parent : QWidget | None 28 | Optional parent widget, by default None. 29 | """ 30 | 31 | def __init__( 32 | self, 33 | rows: int = 0, 34 | mmcore: CMMCorePlus | None = None, 35 | parent: QWidget | None = None, 36 | ): 37 | super().__init__(rows, parent) 38 | self._mmc = mmcore or CMMCorePlus.instance() 39 | 40 | # connections 41 | self._mmc.events.systemConfigurationLoaded.connect(self._update_channel_groups) 42 | self._mmc.events.configGroupDeleted.connect(self._update_channel_groups) 43 | self._mmc.events.configDefined.connect(self._update_channel_groups) 44 | self._mmc.events.channelGroupChanged.connect(self._update_channel_groups) 45 | 46 | self.destroyed.connect(self._disconnect) 47 | 48 | self._update_channel_groups() 49 | 50 | def _update_channel_groups(self) -> None: 51 | """Update the channel groups when the system configuration is loaded.""" 52 | self.setChannelGroups( 53 | { 54 | group: self._mmc.getAvailableConfigs(group) 55 | for group in self._mmc.getAvailableConfigGroups() 56 | } 57 | ) 58 | 59 | ch_group = self._mmc.getChannelGroup() 60 | if ch_group and ch_group in self.channelGroups(): 61 | self._group_combo.setCurrentText(ch_group) 62 | 63 | def _disconnect(self) -> None: 64 | """Disconnect from the core instance.""" 65 | self._mmc.events.systemConfigurationLoaded.disconnect( 66 | self._update_channel_groups 67 | ) 68 | self._mmc.events.configGroupDeleted.disconnect(self._update_channel_groups) 69 | self._mmc.events.configDefined.disconnect(self._update_channel_groups) 70 | self._mmc.events.channelGroupChanged.disconnect(self._update_channel_groups) 71 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/mda/_core_grid.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pymmcore_plus import CMMCorePlus 6 | 7 | from pymmcore_widgets.useq_widgets._grid import GridPlanWidget, Mode 8 | 9 | from ._xy_bounds import CoreXYBoundsControl 10 | 11 | if TYPE_CHECKING: 12 | from qtpy.QtWidgets import QWidget 13 | 14 | 15 | class CoreConnectedGridPlanWidget(GridPlanWidget): 16 | """[GridPlanWidget](../GridPlanWidget#) connected to a Micro-Manager core instance. 17 | 18 | Parameters 19 | ---------- 20 | mmcore : CMMCorePlus | None 21 | Optional [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] micromanager core. 22 | By default, None. If not specified, the widget will use the active 23 | (or create a new) 24 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 25 | parent : QWidget | None 26 | Optional parent widget, by default None. 27 | """ 28 | 29 | def __init__( 30 | self, mmcore: CMMCorePlus | None = None, parent: QWidget | None = None 31 | ) -> None: 32 | self._mmc = mmcore or CMMCorePlus.instance() 33 | self._core_xy_bounds = CoreXYBoundsControl(core=self._mmc) 34 | 35 | super().__init__(parent) 36 | 37 | # replace self._mode_to_widget[Mode.BOUNDS] with self._core_xy_bounds 38 | self._mode_to_widget[Mode.BOUNDS] = self._core_xy_bounds 39 | 40 | # remove self.bounds_wdg from GridPlanWidget 41 | self._stack.removeWidget(self.bounds_wdg) 42 | self.bounds_wdg.hide() 43 | # add CoreXYBoundsControl widget to GridPlanWidget 44 | self._stack.addWidget(self._core_xy_bounds) 45 | 46 | self._mmc.events.systemConfigurationLoaded.connect(self._update_fov_size) 47 | self._mmc.events.pixelSizeChanged.connect(self._update_fov_size) 48 | self._update_fov_size() 49 | 50 | def _update_fov_size(self) -> None: 51 | """Update the FOV size in the grid plan widget.""" 52 | if px := self._mmc.getPixelSizeUm(): 53 | self.setFovWidth(self._mmc.getImageWidth() * px) 54 | self.setFovHeight(self._mmc.getImageHeight() * px) 55 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/mda/_core_z.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal 4 | 5 | from fonticon_mdi6 import MDI6 6 | from pymmcore_plus import CMMCorePlus 7 | 8 | from pymmcore_widgets.useq_widgets._z import ROW_TOP_BOTTOM, Mode, ZPlanWidget 9 | 10 | from ._xy_bounds import MarkVisit 11 | 12 | if TYPE_CHECKING: 13 | from qtpy.QtWidgets import QWidget 14 | 15 | 16 | class CoreConnectedZPlanWidget(ZPlanWidget): 17 | """[ZPlanWidget](../ZPlanWidget#) connected to a Micro-Manager core instance. 18 | 19 | Parameters 20 | ---------- 21 | mmcore : CMMCorePlus | None 22 | Optional [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] micromanager core. 23 | By default, None. If not specified, the widget will use the active 24 | (or create a new) 25 | [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. 26 | parent : QWidget | None 27 | Optional parent widget, by default None. 28 | """ 29 | 30 | def __init__( 31 | self, mmcore: CMMCorePlus | None = None, parent: QWidget | None = None 32 | ) -> None: 33 | self.bottom_btn = MarkVisit( 34 | MDI6.arrow_collapse_down, mark_text="Mark Bottom", icon_size=16 35 | ) 36 | self.top_btn = MarkVisit( 37 | MDI6.arrow_collapse_up, mark_text="Mark Top", icon_size=16 38 | ) 39 | 40 | super().__init__(parent) 41 | self._mmc = mmcore or CMMCorePlus.instance() 42 | 43 | self.bottom_btn.mark.clicked.connect(self._mark_bottom) 44 | self.top_btn.mark.clicked.connect(self._mark_top) 45 | self.bottom_btn.visit.clicked.connect(self._visit_bottom) 46 | self.top_btn.visit.clicked.connect(self._visit_top) 47 | 48 | row = ROW_TOP_BOTTOM + 1 # --------------- Bottom / Top parameters 49 | self._grid_layout.addWidget(self.bottom_btn, row, 1) 50 | self._grid_layout.addWidget(self.top_btn, row, 4) 51 | 52 | def setMode( 53 | self, 54 | mode: Mode | Literal["top_bottom", "range_around", "above_below"], 55 | ) -> None: 56 | super().setMode(mode) 57 | self.bottom_btn.setVisible(self._mode == Mode.TOP_BOTTOM) 58 | self.top_btn.setVisible(self._mode == Mode.TOP_BOTTOM) 59 | 60 | def _mark_bottom(self) -> None: 61 | self.bottom.setValue(self._mmc.getZPosition()) 62 | 63 | def _mark_top(self) -> None: 64 | self.top.setValue(self._mmc.getZPosition()) 65 | 66 | def _visit_bottom(self) -> None: 67 | self._mmc.setZPosition(self.bottom.value()) 68 | 69 | def _visit_top(self) -> None: 70 | self._mmc.setZPosition(self.top.value()) 71 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymmcore-plus/pymmcore-widgets/7d4f2b3e85fb39e81c10e4eeef98eef6fc5649b4/src/pymmcore_widgets/py.typed -------------------------------------------------------------------------------- /src/pymmcore_widgets/useq_widgets/__init__.py: -------------------------------------------------------------------------------- 1 | """Widgets for the useq-schema data model.""" 2 | 3 | from ._channels import ChannelTable 4 | from ._column_info import ( 5 | BoolColumn, 6 | ChoiceColumn, 7 | FloatColumn, 8 | IntColumn, 9 | TextColumn, 10 | TimeDeltaColumn, 11 | ) 12 | from ._data_table import DataTable, DataTableWidget 13 | from ._grid import GridPlanWidget 14 | from ._mda_sequence import PYMMCW_METADATA_KEY, MDASequenceWidget 15 | from ._positions import PositionTable 16 | from ._time import TimePlanWidget 17 | from ._well_plate_widget import WellPlateWidget 18 | from ._z import ZPlanWidget 19 | from .points_plans import PointsPlanWidget 20 | 21 | __all__ = [ 22 | "PYMMCW_METADATA_KEY", 23 | "BoolColumn", 24 | "ChannelTable", 25 | "ChoiceColumn", 26 | "DataTable", 27 | "DataTableWidget", 28 | "FloatColumn", 29 | "GridPlanWidget", 30 | "IntColumn", 31 | "MDASequenceWidget", 32 | "PointsPlanWidget", 33 | "PositionTable", 34 | "TextColumn", 35 | "TimeDeltaColumn", 36 | "TimePlanWidget", 37 | "WellPlateWidget", 38 | "ZPlanWidget", 39 | ] 40 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/useq_widgets/points_plans/__init__.py: -------------------------------------------------------------------------------- 1 | """Widgets that create MultiPoint plans.""" 2 | 3 | from ._grid_row_column_widget import GridRowColumnWidget 4 | from ._points_plan_selector import RelativePointPlanSelector 5 | from ._points_plan_widget import PointsPlanWidget 6 | from ._random_points_widget import RandomPointWidget 7 | 8 | __all__ = [ 9 | "GridRowColumnWidget", 10 | "PointsPlanWidget", 11 | "RandomPointWidget", 12 | "RelativePointPlanSelector", 13 | ] 14 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from qtpy.QtCore import Qt, Signal 6 | from qtpy.QtWidgets import ( 7 | QComboBox, 8 | QDoubleSpinBox, 9 | QFormLayout, 10 | QLabel, 11 | QSpinBox, 12 | QVBoxLayout, 13 | QWidget, 14 | ) 15 | from useq import GridRowsColumns, OrderMode 16 | 17 | if TYPE_CHECKING: 18 | from collections.abc import Mapping 19 | 20 | 21 | class GridRowColumnWidget(QWidget): 22 | """Widget to generate a grid of FOVs within a specified area.""" 23 | 24 | valueChanged = Signal(object) 25 | 26 | def __init__(self, parent: QWidget | None = None) -> None: 27 | super().__init__(parent) 28 | 29 | self.fov_width: float | None = None 30 | self.fov_height: float | None = None 31 | self._relative_to: str = "center" 32 | 33 | # title 34 | title = QLabel(text="Fields of View in a Grid") 35 | title.setStyleSheet("font-weight: bold;") 36 | title.setAlignment(Qt.AlignmentFlag.AlignCenter) 37 | 38 | # rows 39 | self.rows = QSpinBox() 40 | self.rows.setAlignment(Qt.AlignmentFlag.AlignCenter) 41 | self.rows.setMinimum(1) 42 | self.rows.setValue(3) 43 | # columns 44 | self.columns = QSpinBox() 45 | self.columns.setAlignment(Qt.AlignmentFlag.AlignCenter) 46 | self.columns.setMinimum(1) 47 | self.columns.setValue(3) 48 | # overlap along x 49 | self.overlap_x = QDoubleSpinBox() 50 | self.overlap_x.setAlignment(Qt.AlignmentFlag.AlignCenter) 51 | self.overlap_x.setRange(-10000, 100) 52 | # overlap along y 53 | self.overlap_y = QDoubleSpinBox() 54 | self.overlap_y.setAlignment(Qt.AlignmentFlag.AlignCenter) 55 | self.overlap_y.setRange(-10000, 100) 56 | # order combo 57 | self.mode = QComboBox() 58 | self.mode.addItems([mode.value for mode in OrderMode]) 59 | self.mode.setCurrentText(OrderMode.row_wise_snake.value) 60 | 61 | # form layout 62 | form = QFormLayout() 63 | form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) 64 | form.setSpacing(5) 65 | form.setContentsMargins(0, 0, 0, 0) 66 | form.addRow("Rows:", self.rows) 67 | form.addRow("Columns:", self.columns) 68 | form.addRow("Overlap x (%):", self.overlap_x) 69 | form.addRow("Overlap y (%):", self.overlap_y) 70 | form.addRow("Grid Order:", self.mode) 71 | 72 | # main 73 | main_layout = QVBoxLayout(self) 74 | main_layout.setSpacing(5) 75 | main_layout.setContentsMargins(10, 10, 10, 10) 76 | main_layout.addWidget(title) 77 | main_layout.addLayout(form) 78 | 79 | # connect 80 | self.rows.valueChanged.connect(self._on_value_changed) 81 | self.columns.valueChanged.connect(self._on_value_changed) 82 | self.overlap_x.valueChanged.connect(self._on_value_changed) 83 | self.overlap_y.valueChanged.connect(self._on_value_changed) 84 | self.mode.currentTextChanged.connect(self._on_value_changed) 85 | 86 | def _on_value_changed(self) -> None: 87 | """Emit the valueChanged signal.""" 88 | self.valueChanged.emit(self.value()) 89 | 90 | @property 91 | def overlap(self) -> tuple[float, float]: 92 | """Return the overlap along x and y.""" 93 | return self.overlap_x.value(), self.overlap_y.value() 94 | 95 | @property 96 | def fov_size(self) -> tuple[float | None, float | None]: 97 | """Return the FOV size in (width, height).""" 98 | return self.fov_width, self.fov_height 99 | 100 | @fov_size.setter 101 | def fov_size(self, size: tuple[float | None, float | None]) -> None: 102 | """Set the FOV size.""" 103 | self.fov_width, self.fov_height = size 104 | 105 | def value(self) -> GridRowsColumns: 106 | """Return the values of the widgets.""" 107 | return GridRowsColumns( 108 | rows=self.rows.value(), 109 | columns=self.columns.value(), 110 | overlap=self.overlap, 111 | mode=self.mode.currentText(), 112 | fov_width=self.fov_width, 113 | fov_height=self.fov_height, 114 | relative_to=self._relative_to, 115 | ) 116 | 117 | def setValue(self, value: GridRowsColumns | Mapping) -> None: 118 | """Set the values of the widgets.""" 119 | value = GridRowsColumns.model_validate(value) 120 | self.rows.setValue(value.rows) 121 | self.columns.setValue(value.columns) 122 | self.overlap_x.setValue(value.overlap[0]) 123 | self.overlap_y.setValue(value.overlap[1]) 124 | self.mode.setCurrentText(value.mode.value) 125 | self.fov_width = value.fov_width 126 | self.fov_height = value.fov_height 127 | self._relative_to = value.relative_to.value 128 | 129 | def reset(self) -> None: 130 | """Reset value to 1x1, row-wise-snake, with 0 overlap.""" 131 | self.rows.setValue(1) 132 | self.columns.setValue(1) 133 | self.overlap_x.setValue(0) 134 | self.overlap_y.setValue(0) 135 | self.mode.setCurrentText(OrderMode.row_wise_snake.value) 136 | self.fov_size = (None, None) 137 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import useq 4 | from qtpy.QtCore import Signal 5 | from qtpy.QtWidgets import QHBoxLayout, QWidget 6 | 7 | from pymmcore_widgets.useq_widgets.points_plans import RelativePointPlanSelector 8 | 9 | from ._well_graphics_view import WellView 10 | 11 | 12 | class PointsPlanWidget(QWidget): 13 | """Widget to select the FOVVs per well of the plate. 14 | 15 | This widget allows the user to select the number of FOVs per well, (or to generally 16 | show a multi-point plan, such as a grid or random points plan, even if not within 17 | the context of a well plate.) 18 | 19 | The value() method returns the selected plan, one of: 20 | - [useq.GridRowsColumns][] 21 | - [useq.RandomPoints][] 22 | - [useq.RelativePosition][] 23 | 24 | Parameters 25 | ---------- 26 | plan : useq.RelativeMultiPointPlan | None 27 | The useq MultiPoint plan to display and edit. 28 | parent : QWidget | None 29 | The parent widget. 30 | """ 31 | 32 | valueChanged = Signal(object) 33 | 34 | def __init__( 35 | self, 36 | plan: useq.RelativeMultiPointPlan | None = None, 37 | parent: QWidget | None = None, 38 | ) -> None: 39 | super().__init__(parent=parent) 40 | 41 | self._selector = RelativePointPlanSelector() 42 | # aliases 43 | self.single_pos_wdg = self._selector.single_pos_wdg 44 | self.random_points_wdg = self._selector.random_points_wdg 45 | self.grid_wdg = self._selector.grid_wdg 46 | 47 | # graphics scene to draw the well and the fovs 48 | self._well_view = WellView() 49 | 50 | # main 51 | layout = QHBoxLayout(self) 52 | layout.addWidget(self._selector, 1) 53 | layout.addWidget(self._well_view, 2) 54 | 55 | # connect 56 | self._selector.valueChanged.connect(self._on_selector_value_changed) 57 | self._well_view.maxPointsDetected.connect(self._on_view_max_points_detected) 58 | self._well_view.positionClicked.connect(self._on_view_position_clicked) 59 | 60 | if plan is not None: 61 | self.setValue(plan) 62 | 63 | def value(self) -> useq.RelativeMultiPointPlan: 64 | """Return the selected plan.""" 65 | return self._selector.value() 66 | 67 | def setValue(self, plan: useq.RelativeMultiPointPlan) -> None: 68 | """Set the current plan.""" 69 | self._selector.setValue(plan) 70 | 71 | def setWellSize( 72 | self, width: float | None = None, height: float | None = None 73 | ) -> None: 74 | """Set the well size width and/or height in mm.""" 75 | self._well_view.setWellSize(width, height) 76 | 77 | def setWellShape(self, shape: useq.Shape | str) -> None: 78 | """Set the shape of the well. 79 | 80 | Can be a `useq.Shape` enum or the strings "circle", "ellipse", 81 | "square", or "rectangle". 82 | """ 83 | if isinstance(shape, str): 84 | if shape.lower() == "circle": 85 | shape = useq.Shape.ELLIPSE 86 | elif shape.lower() == "square": 87 | shape = useq.Shape.RECTANGLE 88 | shape = useq.Shape(shape) 89 | self._well_view.setWellCircular(shape == useq.Shape.ELLIPSE) 90 | 91 | def _on_selector_value_changed(self, value: useq.RelativeMultiPointPlan) -> None: 92 | self._well_view.setPointsPlan(value) 93 | self.valueChanged.emit(value) 94 | 95 | def _on_view_max_points_detected(self, value: int) -> None: 96 | self.random_points_wdg.num_points.setValue(value) 97 | 98 | def _on_view_position_clicked(self, position: useq.RelativePosition) -> None: 99 | if self._selector.active_plan_type is useq.RandomPoints: 100 | pos_no_name = position.model_copy(update={"name": ""}) 101 | self.random_points_wdg.start_at = pos_no_name 102 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/views/__init__.py: -------------------------------------------------------------------------------- 1 | """View-related Widgets.""" 2 | 3 | from ._image_widget import ImagePreview 4 | 5 | __all__ = ["ImagePreview"] 6 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/views/_stack_viewer/__init__.py: -------------------------------------------------------------------------------- 1 | from ._channel_row import CMAPS 2 | from ._datastore import QOMEZarrDatastore 3 | from ._stack_viewer import StackViewer 4 | 5 | __all__ = ["CMAPS", "QOMEZarrDatastore", "StackViewer"] 6 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/views/_stack_viewer/_datastore.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from psygnal import Signal 6 | from pymmcore_plus.mda.handlers._ome_zarr_writer import POS_PREFIX, OMEZarrWriter 7 | from useq import MDAEvent 8 | 9 | if TYPE_CHECKING: 10 | import numpy as np 11 | import useq 12 | from pymmcore_plus.metadata import FrameMetaV1, SummaryMetaV1 13 | 14 | 15 | class QOMEZarrDatastore(OMEZarrWriter): 16 | frame_ready = Signal(MDAEvent) 17 | 18 | def __init__(self) -> None: 19 | super().__init__(store=None) 20 | 21 | def sequenceStarted(self, seq: useq.MDASequence, meta: SummaryMetaV1) -> None: # type: ignore[override] 22 | self._used_axes = tuple(seq.used_axes) 23 | super().sequenceStarted(seq, meta) 24 | 25 | def frameReady( 26 | self, frame: np.ndarray, event: useq.MDAEvent, meta: FrameMetaV1 27 | ) -> None: 28 | super().frameReady(frame, event, meta) 29 | self.frame_ready.emit(event) 30 | 31 | def get_frame(self, event: MDAEvent) -> np.ndarray: 32 | key = f"{POS_PREFIX}{event.index.get('p', 0)}" 33 | ary = self.position_arrays[key] 34 | 35 | index = tuple(event.index.get(k) for k in self._used_axes) 36 | data: np.ndarray = ary[index] 37 | return data 38 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/views/_stack_viewer/_labeled_slider.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, cast 4 | 5 | import superqt 6 | from fonticon_mdi6 import MDI6 7 | from qtpy import QtCore, QtWidgets 8 | from superqt.fonticon import icon 9 | 10 | FIXED = QtWidgets.QSizePolicy.Policy.Fixed 11 | 12 | 13 | class QLabeledSlider(superqt.QLabeledSlider): 14 | def __init__( 15 | self, 16 | name: str = "", 17 | orientation: QtCore.Qt.Orientation = QtCore.Qt.Orientation.Horizontal, 18 | parent: QtWidgets.QWidget | None = None, 19 | ) -> None: 20 | super().__init__(orientation, parent) 21 | self.name = name 22 | name_label = QtWidgets.QLabel(name.upper()) 23 | 24 | self._length_label = QtWidgets.QLabel() 25 | self.rangeChanged.connect(self._on_range_changed) 26 | 27 | self.play_btn = QtWidgets.QPushButton(icon(MDI6.play, color="gray"), "", self) 28 | self.play_btn.setMaximumWidth(24) 29 | self.play_btn.setCheckable(True) 30 | self.play_btn.toggled.connect(self._on_play_toggled) 31 | 32 | self.lock_btn = QtWidgets.QPushButton( 33 | icon(MDI6.lock_open_outline, color="gray"), "", self 34 | ) 35 | self.lock_btn.setCheckable(True) 36 | self.lock_btn.setMaximumWidth(24) 37 | self.lock_btn.toggled.connect(self._on_lock_toggled) 38 | 39 | layout = cast("QtWidgets.QBoxLayout", self.layout()) 40 | layout.insertWidget(0, self.play_btn, 0, QtCore.Qt.AlignmentFlag.AlignRight) 41 | layout.insertWidget(0, name_label) 42 | # FIXME: the padding/vertical alignment is a bit off here 43 | layout.addWidget(self._length_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) 44 | layout.addWidget(self.lock_btn, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) 45 | 46 | self.installEventFilter(self) 47 | self.setPageStep(1) 48 | self.last_val = 0 49 | 50 | def _on_play_toggled(self, state: bool) -> None: 51 | if state: 52 | self.play_btn.setIcon(icon(MDI6.pause)) 53 | self._timer_id = self.startTimer(50) 54 | else: 55 | self.play_btn.setIcon(icon(MDI6.play)) 56 | self.killTimer(self._timer_id) 57 | 58 | def _on_lock_toggled(self, state: bool) -> None: 59 | if state: 60 | self.lock_btn.setIcon(icon(MDI6.lock_outline, color="red")) 61 | else: 62 | self.lock_btn.setIcon(icon(MDI6.lock_open_outline, color="gray")) 63 | 64 | def timerEvent(self, e: QtCore.QTimerEvent) -> None: 65 | self.setValue((self.value() + 1) % self.maximum()) 66 | 67 | def _on_range_changed(self, min_: int, max_: int) -> None: 68 | self._length_label.setText(f"/ {max_}") 69 | 70 | def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> Any: 71 | if event.type() == QtCore.QEvent.Type.Paint and self.underMouse(): 72 | if self.value() != self.last_val: 73 | self.sliderMoved.emit(self.value()) 74 | self.last_val = self.value() 75 | return super().eventFilter(source, event) 76 | 77 | 78 | class LabeledVisibilitySlider(QLabeledSlider): 79 | def _visibility(self, settings: dict[str, Any]) -> None: 80 | if settings["index"] != self.name: 81 | return 82 | if settings["show"]: 83 | self.show() 84 | else: 85 | self.hide() 86 | self.setRange(0, settings["max"]) 87 | -------------------------------------------------------------------------------- /src/pymmcore_widgets/views/_stack_viewer/_save_button.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | import zarr 7 | from fonticon_mdi6 import MDI6 8 | from qtpy.QtCore import QSize 9 | from qtpy.QtWidgets import QFileDialog, QPushButton, QWidget 10 | from superqt import fonticon 11 | 12 | from ._datastore import QOMEZarrDatastore 13 | 14 | if TYPE_CHECKING: 15 | from qtpy.QtGui import QCloseEvent 16 | 17 | 18 | class SaveButton(QPushButton): 19 | def __init__( 20 | self, 21 | datastore: QOMEZarrDatastore, 22 | parent: QWidget | None = None, 23 | ): 24 | super().__init__(parent=parent) 25 | # self.setFont(QFont('Arial', 50)) 26 | # self.setMinimumHeight(30) 27 | self.setIcon(fonticon.icon(MDI6.content_save_outline, color="gray")) 28 | self.setIconSize(QSize(25, 25)) 29 | self.setFixedSize(30, 30) 30 | self.clicked.connect(self._on_click) 31 | 32 | self.datastore = datastore 33 | self.save_loc = Path.home() 34 | 35 | def _on_click(self) -> None: 36 | self.save_loc, _ = QFileDialog.getSaveFileName(directory=str(self.save_loc)) 37 | if self.save_loc: 38 | self._save_as_zarr(self.save_loc) 39 | 40 | def _save_as_zarr(self, save_loc: str | Path) -> None: 41 | dir_store = zarr.DirectoryStore(save_loc) 42 | zarr.copy_store(self.datastore._group.attrs.store, dir_store) 43 | 44 | def closeEvent(self, a0: QCloseEvent | None) -> None: 45 | super().closeEvent(a0) 46 | 47 | 48 | if __name__ == "__main__": 49 | from pymmcore_plus import CMMCorePlus 50 | from qtpy.QtWidgets import QApplication 51 | from useq import MDASequence 52 | 53 | mmc = CMMCorePlus() 54 | mmc.loadSystemConfiguration() 55 | 56 | app = QApplication([]) 57 | seq = MDASequence( 58 | time_plan={"interval": 0.01, "loops": 10}, 59 | z_plan={"range": 5, "step": 1}, 60 | channels=[{"config": "DAPI", "exposure": 1}, {"config": "FITC", "exposure": 1}], 61 | ) 62 | datastore = QOMEZarrDatastore() 63 | mmc.mda.events.sequenceStarted.connect(datastore.sequenceStarted) 64 | mmc.mda.events.frameReady.connect(datastore.frameReady) 65 | 66 | widget = SaveButton(datastore) 67 | mmc.run_mda(seq) 68 | widget.show() 69 | app.exec_() 70 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections.abc import Iterator 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | from pymmcore_plus import CMMCorePlus 9 | from pymmcore_plus.core import _mmcore_plus 10 | 11 | if TYPE_CHECKING: 12 | from pytest import FixtureRequest 13 | from qtpy.QtWidgets import QApplication 14 | 15 | TEST_CONFIG = str(Path(__file__).parent / "test_config.cfg") 16 | 17 | 18 | # to create a new CMMCorePlus() for every test 19 | @pytest.fixture(autouse=True) 20 | def global_mmcore() -> Iterator[CMMCorePlus]: 21 | mmc = CMMCorePlus() 22 | mmc.loadSystemConfiguration(TEST_CONFIG) 23 | with patch.object(_mmcore_plus, "_instance", mmc): 24 | yield mmc 25 | 26 | 27 | @pytest.fixture(autouse=True) 28 | def _run_after_each_test( 29 | request: "FixtureRequest", qapp: "QApplication" 30 | ) -> Iterator[None]: 31 | """Run after each test to ensure no widgets have been left around. 32 | 33 | When this test fails, it means that a widget being tested has an issue closing 34 | cleanly. Perhaps a strong reference has leaked somewhere. Look for 35 | `functools.partial(self._method)` or `lambda: self._method` being used in that 36 | widget's code. 37 | """ 38 | nbefore = len(qapp.topLevelWidgets()) 39 | failures_before = request.session.testsfailed 40 | yield 41 | # if the test failed, don't worry about checking widgets 42 | if request.session.testsfailed - failures_before: 43 | return 44 | remaining = qapp.topLevelWidgets() 45 | if len(remaining) > nbefore: 46 | if ( 47 | # os.name == "nt" 48 | # and sys.version_info[:2] <= (3, 9) 49 | type(remaining[0]).__name__ in {"ImagePreview", "SnapButton"} 50 | ): 51 | # I have no idea why, but the ImagePreview widget is leaking. 52 | # And it only came with a seemingly unrelated 53 | # https://github.com/pymmcore-plus/pymmcore-widgets/pull/90 54 | # we're just ignoring it for now. 55 | return 56 | 57 | test = f"{request.node.path.name}::{request.node.originalname}" 58 | warnings.warn( 59 | f"topLevelWidgets remaining after {test!r}: {remaining}", 60 | UserWarning, 61 | stacklevel=2, 62 | ) 63 | -------------------------------------------------------------------------------- /tests/hcs/test_hcs_wizard.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import useq 6 | 7 | from pymmcore_widgets.hcs import HCSWizard 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from pytestqt.qtbot import QtBot 12 | 13 | 14 | def test_hcs_wizard(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 15 | """Test the HCSWizard.""" 16 | 17 | plan = useq.WellPlatePlan( 18 | plate="96-well", 19 | a1_center_xy=(1000, 1500), 20 | rotation=0.3, 21 | selected_wells=slice(0, 8, 2), 22 | ) 23 | 24 | wdg = HCSWizard(mmcore=global_mmcore) 25 | wdg.setValue(plan) 26 | qtbot.addWidget(wdg) 27 | wdg.show() 28 | wdg.next() 29 | wdg.next() 30 | wdg.next() 31 | wdg.accept() 32 | 33 | # we haven't done anything, the plan should be the same 34 | assert wdg.value() == plan 35 | -------------------------------------------------------------------------------- /tests/test_channel_group_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pymmcore_widgets import ChannelGroupWidget 6 | 7 | if TYPE_CHECKING: 8 | from pytestqt.qtbot import QtBot 9 | 10 | 11 | def test_channel_group_widget(qtbot: QtBot): 12 | ch = ChannelGroupWidget() 13 | qtbot.addWidget(ch) 14 | mmc = ch._mmc 15 | 16 | assert mmc.getChannelGroup() == "Channel" 17 | assert ch.currentText() == "Channel" 18 | 19 | mmc.setProperty("Core", "ChannelGroup", "Camera") 20 | assert ch.currentText() == "Camera" 21 | assert mmc.getChannelGroup() == "Camera" 22 | 23 | mmc.setProperty("Core", "ChannelGroup", "") 24 | assert not mmc.getChannelGroup() 25 | assert ch.currentText() == "Camera" 26 | assert ch.styleSheet() == "color: magenta;" 27 | 28 | mmc.setChannelGroup("Channel") 29 | assert ch.currentText() == "Channel" 30 | assert mmc.getChannelGroup() == "Channel" 31 | assert not ch.styleSheet() 32 | 33 | mmc.deleteConfigGroup("Channel") 34 | assert not mmc.getChannelGroup() 35 | assert ch.currentText() == "Camera" 36 | assert ch.styleSheet() == "color: magenta;" 37 | assert "Channel" not in [ch.itemText(idx) for idx in range(ch.count())] 38 | 39 | mmc.defineConfig("test_group", "test_preset") 40 | assert "test_group" in [ch.itemText(idx) for idx in range(ch.count())] 41 | 42 | ch._disconnect() 43 | mmc.setProperty("Core", "ChannelGroup", "LightPath") 44 | assert ch.currentText() == "Camera" 45 | assert mmc.getChannelGroup() == "LightPath" 46 | -------------------------------------------------------------------------------- /tests/test_channel_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from qtpy.QtWidgets import QComboBox 6 | 7 | from pymmcore_widgets._util import block_core 8 | from pymmcore_widgets.control._channel_widget import ChannelWidget 9 | from pymmcore_widgets.control._presets_widget import PresetsWidget 10 | 11 | if TYPE_CHECKING: 12 | from pymmcore_plus import CMMCorePlus 13 | from pytestqt.qtbot import QtBot 14 | 15 | 16 | def test_channel_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): 17 | wdg = ChannelWidget() 18 | qtbot.addWidget(wdg) 19 | 20 | assert global_mmcore.getChannelGroup() == "Channel" 21 | 22 | assert isinstance(wdg.channel_wdg, PresetsWidget) 23 | 24 | wdg.channel_wdg.setValue("DAPI") 25 | assert global_mmcore.getCurrentConfig("Channel") == "DAPI" 26 | assert global_mmcore.getShutterDevice() == "Shutter" 27 | 28 | global_mmcore.setConfig("Channel", "FITC") 29 | assert wdg.channel_wdg.value() == "FITC" 30 | 31 | global_mmcore.setProperty("Emission", "Label", "Chroma-HQ700") 32 | assert wdg.channel_wdg._combo.styleSheet() == "color: magenta;" 33 | 34 | with qtbot.waitSignal(global_mmcore.events.channelGroupChanged): 35 | global_mmcore.setChannelGroup("") 36 | assert isinstance(wdg.channel_wdg, QComboBox) 37 | assert wdg.channel_wdg.count() == 0 38 | 39 | global_mmcore.setChannelGroup("Channel") 40 | assert isinstance(wdg.channel_wdg, PresetsWidget) 41 | assert len(wdg.channel_wdg.allowedValues()) == 4 42 | 43 | with qtbot.waitSignal(global_mmcore.events.configDeleted): 44 | global_mmcore.deleteConfig("Channel", "DAPI") 45 | 46 | assert "DAPI" not in global_mmcore.getAvailableConfigs("Channel") 47 | assert "DAPI" not in wdg.channel_wdg.allowedValues() 48 | 49 | with qtbot.waitSignal(global_mmcore.events.configGroupDeleted): 50 | global_mmcore.deleteConfigGroup("Channel") 51 | assert isinstance(wdg.channel_wdg, QComboBox) 52 | assert wdg.channel_wdg.count() == 0 53 | assert global_mmcore.getChannelGroup() == "" 54 | 55 | with qtbot.waitSignal(global_mmcore.events.configDefined): 56 | dev_prop_val = [ 57 | ("Dichroic", "Label", "400DCLP"), 58 | ("Emission", "Label", "Chroma-HQ700"), 59 | ("Excitation", "Label", "Chroma-HQ570"), 60 | ] 61 | 62 | with block_core(global_mmcore.events): 63 | for d, p, v in dev_prop_val: 64 | global_mmcore.defineConfig("Channels", "DAPI", d, p, v) 65 | 66 | global_mmcore.events.configDefined.emit("Channels", "DAPI", d, p, v) 67 | 68 | assert isinstance(wdg.channel_wdg, PresetsWidget) 69 | assert len(wdg.channel_wdg.allowedValues()) == 1 70 | assert global_mmcore.getChannelGroup() == "Channels" 71 | -------------------------------------------------------------------------------- /tests/test_combo_message_box_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pymmcore_widgets._util import ComboMessageBox 6 | 7 | if TYPE_CHECKING: 8 | from pytestqt.qtbot import QtBot 9 | 10 | 11 | def test_combo_message_box_widget(qtbot: QtBot): 12 | items = ["item_1", "item_2", "item_3"] 13 | 14 | wdg = ComboMessageBox(items) 15 | qtbot.add_widget(wdg) 16 | 17 | assert wdg._combo.count() == 3 18 | 19 | wdg._combo.setCurrentIndex(1) 20 | assert wdg._combo.currentText() == "item_2" 21 | 22 | wdg._combo.setCurrentText("item_3") 23 | assert wdg._combo.currentText() == "item_3" 24 | -------------------------------------------------------------------------------- /tests/test_core_log_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from qtpy.QtWidgets import QApplication 6 | 7 | from pymmcore_widgets import CoreLogWidget 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from pytestqt.qtbot import QtBot 12 | 13 | 14 | def test_core_log_widget_init(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 15 | """Asserts that the CoreLogWidget initializes with the entire log to this point.""" 16 | wdg = CoreLogWidget() 17 | qtbot.addWidget(wdg) 18 | 19 | # Assert log path is in the widget LineEdit 20 | log_path = global_mmcore.getPrimaryLogFile() 21 | assert log_path == wdg._log_path.text() 22 | 23 | # Assert log content is in the widget TextEdit 24 | # This is a bit tricky because more can be appended to the log file. 25 | with open(log_path) as f: 26 | log_content = [s.strip() for s in f.readlines()] 27 | # Trim down to the final 5000 lines if necessary 28 | # (this is all that will fit in the Log Widget) 29 | max_lines = wdg._log_view.maximumBlockCount() 30 | if len(log_content) > max_lines: 31 | log_content = log_content[-max_lines:] 32 | edit_content = [s.strip() for s in wdg._log_view.toPlainText().splitlines()] 33 | min_length = min(len(log_content), len(edit_content)) 34 | for i in range(min_length): 35 | assert log_content[i] == edit_content[i] 36 | 37 | 38 | def test_core_log_widget_update(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 39 | wdg = CoreLogWidget() 40 | qtbot.addWidget(wdg) 41 | # Remove some lines for faster checking later 42 | wdg._log_view.clear() 43 | 44 | # Log something new 45 | new_message = "Test message" 46 | global_mmcore.logMessage(new_message) 47 | 48 | def wait_for_update() -> None: 49 | # Sometimes, our new message will be flushed before other initialization 50 | # completes. Thus we need to check all lines after what is currently written to 51 | # the TextEdit. 52 | all_lines = wdg._log_view.toPlainText().splitlines() 53 | for line in reversed(all_lines): 54 | if f"[IFO,App] {new_message}" in line: 55 | return 56 | raise AssertionError("New message not found in CoreLogWidget.") 57 | 58 | qtbot.waitUntil(wait_for_update, timeout=1000) 59 | 60 | 61 | def test_core_log_widget_clear(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 62 | wdg = CoreLogWidget() 63 | qtbot.addWidget(wdg) 64 | 65 | assert wdg._log_view.toPlainText() != "" 66 | wdg._clear_btn.click() 67 | assert wdg._log_view.toPlainText() == "" 68 | 69 | 70 | def test_core_log_widget_autoscroll(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 71 | wdg = CoreLogWidget() 72 | qtbot.addWidget(wdg) 73 | # Note that we must show the widget for the scrollbar maximum to be computed 74 | wdg.show() 75 | sb = wdg._log_view.verticalScrollBar() 76 | assert sb is not None 77 | 78 | def add_new_line() -> None: 79 | wdg._append_line("Test message") 80 | QApplication.processEvents() 81 | 82 | # Make sure we have a scrollbar with nonzero size to test with 83 | # But we don't want it full yet 84 | wdg._log_view.clear() 85 | while sb.maximum() == 0: 86 | add_new_line() 87 | 88 | # Assert that adding a new line does not scroll if not at the bottom 89 | sb.setValue(sb.minimum()) 90 | add_new_line() 91 | assert sb.value() == sb.minimum() 92 | 93 | # Assert that adding a new line does scroll if at the bottom 94 | old_max = sb.maximum() 95 | sb.setValue(old_max) 96 | add_new_line() 97 | assert sb.maximum() == old_max + 1 98 | assert sb.value() == sb.maximum() 99 | -------------------------------------------------------------------------------- /tests/test_core_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import pytest 6 | 7 | import pymmcore_widgets as pmmw 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from qtpy.QtWidgets import QWidget 12 | 13 | ALL_WIDGETS: dict[type[QWidget], dict[str, Any]] = { 14 | pmmw.PixelConfigurationWidget: {}, 15 | pmmw.CameraRoiWidget: {}, 16 | pmmw.ChannelGroupWidget: {}, 17 | pmmw.ChannelTable: {}, 18 | pmmw.ChannelWidget: {}, 19 | pmmw.ConfigurationWidget: {}, 20 | pmmw.DefaultCameraExposureWidget: {}, 21 | pmmw.ExposureWidget: {}, 22 | pmmw.GridPlanWidget: {}, 23 | pmmw.GroupPresetTableWidget: {}, 24 | pmmw.ImagePreview: {}, 25 | pmmw.LiveButton: {}, 26 | pmmw.MDASequenceWidget: {}, 27 | pmmw.MDAWidget: {}, 28 | pmmw.ObjectivesWidget: {}, 29 | pmmw.ObjectivesPixelConfigurationWidget: {}, 30 | pmmw.PresetsWidget: {"group": "Camera"}, 31 | pmmw.PropertiesWidget: {}, 32 | pmmw.PropertyBrowser: {}, 33 | pmmw.PropertyWidget: {"device_label": "Camera", "prop_name": "Binning"}, 34 | pmmw.ShuttersWidget: {"shutter_device": "Shutter"}, 35 | pmmw.SnapButton: {}, 36 | pmmw.StageWidget: {"device": "XY"}, 37 | pmmw.TimePlanWidget: {}, 38 | pmmw.ZPlanWidget: {}, 39 | pmmw.PositionTable: {}, 40 | } 41 | 42 | 43 | def _full_state(core: CMMCorePlus) -> dict: 44 | state: dict = dict(core.state()) 45 | state.pop("Datetime", None) 46 | for prop in core.iterProperties(): 47 | state[(prop.device, prop.name)] = prop.value 48 | return state 49 | 50 | 51 | @pytest.mark.parametrize("widget", ALL_WIDGETS, ids=lambda x: x.__name__) 52 | def test_core_state_unchanged( 53 | global_mmcore: CMMCorePlus, widget: type[QWidget], qtbot 54 | ) -> None: 55 | before = _full_state(global_mmcore) 56 | kwargs = {**ALL_WIDGETS[widget]} 57 | w = widget(**kwargs) 58 | qtbot.addWidget(w) 59 | after = _full_state(global_mmcore) 60 | assert before == after 61 | 62 | 63 | def test_all_widgets_represented() -> None: 64 | missing_widgets = {cls.__name__ for cls in ALL_WIDGETS}.difference(pmmw.__all__) 65 | if missing_widgets: 66 | raise AssertionError( 67 | f"Some widgets are missing from the ALL_WIDGETS test dict: " 68 | f"{missing_widgets}" 69 | ) 70 | -------------------------------------------------------------------------------- /tests/test_datastore.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import CMMCorePlus 2 | from useq import MDAEvent, MDASequence 3 | 4 | from pymmcore_widgets.views._stack_viewer._datastore import QOMEZarrDatastore 5 | 6 | sequence = MDASequence( 7 | channels=[{"config": "DAPI", "exposure": 10}], 8 | time_plan={"interval": 0.3, "loops": 3}, 9 | ) 10 | 11 | 12 | def test_reception(qtbot): 13 | mmcore = CMMCorePlus.instance() 14 | 15 | datastore = QOMEZarrDatastore() 16 | mmcore.mda.events.frameReady.connect(datastore.frameReady) 17 | mmcore.mda.events.sequenceFinished.connect(datastore.sequenceFinished) 18 | mmcore.mda.events.sequenceStarted.connect(datastore.sequenceStarted) 19 | 20 | with qtbot.waitSignal(datastore.frame_ready, timeout=5000): 21 | mmcore.run_mda(sequence, block=False) 22 | with qtbot.waitSignal(datastore.frame_ready, timeout=5000): 23 | pass 24 | 25 | assert datastore.get_frame(MDAEvent(index={"c": 0, "t": 0})).flatten()[0] != 0 26 | qtbot.wait(1000) 27 | -------------------------------------------------------------------------------- /tests/test_device_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from pymmcore_plus import CMMCorePlus, DeviceType 7 | 8 | if TYPE_CHECKING: 9 | from pytestqt.qtbot import QtBot 10 | 11 | 12 | @pytest.mark.filterwarnings("ignore::DeprecationWarning") 13 | def test_state_device_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 14 | from pymmcore_widgets import DeviceWidget, StateDeviceWidget 15 | 16 | for label in global_mmcore.getLoadedDevicesOfType(DeviceType.StateDevice): 17 | wdg: StateDeviceWidget = DeviceWidget.for_device(label) 18 | qtbot.addWidget(wdg) 19 | wdg.show() 20 | assert wdg.deviceLabel() == label 21 | # assert wdg.deviceName() == "DObjective" 22 | assert global_mmcore.getStateLabel(label) == wdg._combo.currentText() 23 | assert global_mmcore.getState(label) == wdg._combo.currentIndex() 24 | start_state = wdg.state() 25 | 26 | next_state = (wdg.state() + 1) % len(wdg.stateLabels()) 27 | with qtbot.waitSignal(global_mmcore.events.propertyChanged): 28 | global_mmcore.setState(label, next_state) 29 | 30 | assert wdg.state() != start_state 31 | assert wdg.state() == global_mmcore.getState(label) == wdg._combo.currentIndex() 32 | assert ( 33 | wdg.stateLabel() 34 | == global_mmcore.getStateLabel(label) 35 | == wdg._combo.currentText() 36 | ) 37 | 38 | wdg._disconnect() 39 | # once disconnected, core changes shouldn't call out to the widget 40 | global_mmcore.setState(label, start_state) 41 | assert global_mmcore.getStateLabel(label) != wdg._combo.currentText() 42 | -------------------------------------------------------------------------------- /tests/test_exposure_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from pymmcore_widgets import DefaultCameraExposureWidget 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from pytestqt.qtbot import QtBot 12 | 13 | 14 | def test_exposure_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): 15 | global_mmcore.setExposure(15) 16 | wdg = DefaultCameraExposureWidget(mmcore=global_mmcore) 17 | qtbot.addWidget(wdg) 18 | 19 | # check that it get's whatever core is set to. 20 | assert wdg.spinBox.value() == 15 21 | with qtbot.waitSignal(global_mmcore.events.exposureChanged): 22 | global_mmcore.setExposure(30) 23 | assert wdg.spinBox.value() == 30 24 | 25 | with qtbot.wait_signal(global_mmcore.events.exposureChanged): 26 | wdg.spinBox.setValue(45) 27 | assert global_mmcore.getExposure() == 45 28 | 29 | # test updating cameraDevice 30 | global_mmcore.setProperty("Core", "Camera", "") 31 | assert not wdg.isEnabled() 32 | 33 | with pytest.raises(RuntimeError): 34 | wdg.setCamera("blarg") 35 | 36 | # set to an invalid camera name 37 | # should now be disabled. 38 | wdg.setCamera("blarg", force=True) 39 | assert not wdg.isEnabled() 40 | 41 | # reset the camera to a working one 42 | global_mmcore.setProperty("Core", "Camera", "Camera") 43 | with qtbot.wait_signal(global_mmcore.events.exposureChanged): 44 | wdg.spinBox.setValue(0.1) 45 | assert global_mmcore.getExposure() == 0.1 46 | -------------------------------------------------------------------------------- /tests/test_image_preview.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import numpy as np 4 | from pymmcore_plus import CMMCorePlus 5 | 6 | from pymmcore_widgets import ImagePreview 7 | 8 | if TYPE_CHECKING: 9 | from pytestqt.qtbot import QtBot 10 | 11 | 12 | def test_image_preview(qtbot: "QtBot"): 13 | """Test that the exposure widget works.""" 14 | mmcore = CMMCorePlus.instance() 15 | widget = ImagePreview() 16 | qtbot.addWidget(widget) 17 | assert widget._mmc is mmcore 18 | 19 | with qtbot.waitSignal(mmcore.events.imageSnapped): 20 | mmcore.snap() 21 | img = widget._canvas.render() 22 | 23 | with qtbot.waitSignal(mmcore.events.imageSnapped): 24 | mmcore.snap() 25 | img2 = widget._canvas.render() 26 | 27 | assert not np.allclose(img, img2) 28 | 29 | assert not widget.streaming_timer.isActive() 30 | mmcore.startContinuousSequenceAcquisition(1) 31 | assert widget.streaming_timer.isActive() 32 | mmcore.stopSequenceAcquisition() 33 | assert not widget.streaming_timer.isActive() 34 | -------------------------------------------------------------------------------- /tests/test_install_widget.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import sys 4 | from pathlib import Path 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | from pytestqt.qtbot import QtBot 9 | 10 | from pymmcore_widgets import InstallWidget, _install_widget 11 | 12 | LINUX = platform.system() == "Linux" 13 | PY311 = sys.version_info[:2] == (3, 11) 14 | CI = os.getenv("CI", True) 15 | 16 | 17 | @pytest.mark.skipif(bool(LINUX or not CI), reason="enabled CI=1") 18 | def test_install_widget_download(qtbot: QtBot, tmp_path: Path): 19 | wdg = InstallWidget() 20 | qtbot.addWidget(wdg) 21 | 22 | # mock the process of downloading 23 | with patch.object(_install_widget.QThread, "start"): 24 | wdg._install_dest = str(tmp_path) 25 | wdg._on_install_clicked() 26 | wdg._cmd_thread.stdout_ready.emit("emitting stdout") 27 | wdg._cmd_thread.process_finished.emit(0) 28 | 29 | qtbot.waitUntil(lambda: wdg._cmd_thread is None) 30 | assert "emitting stdout" in wdg.feedback_textbox.toPlainText() 31 | 32 | 33 | @pytest.mark.skipif(bool(LINUX or not CI), reason="enabled CI=1") 34 | def test_install_widget(qtbot: QtBot, tmp_path: Path): 35 | wdg = InstallWidget() 36 | qtbot.addWidget(wdg) 37 | wdg.show() 38 | 39 | dest = tmp_path / "MicroManager-2.0.0-gamma" 40 | dest.mkdir() 41 | assert dest.exists() 42 | 43 | with patch.object(_install_widget, "find_micromanager") as mock1: 44 | with patch.object(_install_widget, "_reveal") as rev_mock: 45 | mock1.return_value = [str(dest)] 46 | wdg.table.refresh() 47 | 48 | # test reveal 49 | wdg.table.selectRow(0) 50 | assert wdg._act_reveal.isEnabled() 51 | wdg.table.reveal() 52 | rev_mock.assert_called_once_with(str(dest)) 53 | 54 | with patch.object(_install_widget.QMessageBox, "warning") as mock2: 55 | mock2.return_value = _install_widget.QMessageBox.StandardButton.Yes 56 | wdg.table.uninstall() 57 | 58 | assert not dest.exists() 59 | -------------------------------------------------------------------------------- /tests/test_live_button.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from qtpy.QtCore import QSize 6 | 7 | from pymmcore_widgets.control._live_button_widget import LiveButton 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from pytestqt.qtbot import QtBot 12 | 13 | 14 | def test_live_button_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): 15 | live_btn = LiveButton() 16 | 17 | qtbot.addWidget(live_btn) 18 | 19 | assert live_btn.text() == "Live" 20 | assert live_btn.iconSize() == QSize(30, 30) 21 | assert live_btn.icon_color_on == (0, 255, 0) 22 | assert live_btn.icon_color_off == "magenta" 23 | 24 | # test from direct mmcore signals 25 | with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): 26 | global_mmcore.startContinuousSequenceAcquisition(0) 27 | assert live_btn.text() == "Stop" 28 | 29 | with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): 30 | global_mmcore.stopSequenceAcquisition() 31 | assert not global_mmcore.isSequenceRunning() 32 | assert live_btn.text() == "Live" 33 | 34 | # test when button is pressed 35 | with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): 36 | live_btn.click() 37 | assert live_btn.text() == "Stop" 38 | assert global_mmcore.isSequenceRunning() 39 | 40 | with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): 41 | live_btn.click() 42 | assert not global_mmcore.isSequenceRunning() 43 | assert live_btn.text() == "Live" 44 | 45 | live_btn.icon_color_on = "Red" 46 | assert live_btn._icon_color_on == "Red" 47 | live_btn.icon_color_off = "Green" 48 | assert live_btn._icon_color_off == "Green" 49 | live_btn.button_text_on = "LIVE" 50 | assert live_btn.text() == "LIVE" 51 | live_btn.button_text_off = "STOP" 52 | global_mmcore.startContinuousSequenceAcquisition(0) 53 | assert live_btn.text() == "STOP" 54 | global_mmcore.stopSequenceAcquisition() 55 | assert live_btn.text() == "LIVE" 56 | -------------------------------------------------------------------------------- /tests/test_load_system_cfg_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pymmcore_widgets.control._load_system_cfg_widget import ConfigurationWidget 6 | 7 | if TYPE_CHECKING: 8 | from pymmcore_plus import CMMCorePlus 9 | from pytestqt.qtbot import QtBot 10 | 11 | 12 | def test_load_system_cfg_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): 13 | cfg = ConfigurationWidget() 14 | qtbot.addWidget(cfg) 15 | 16 | global_mmcore.unloadAllDevices() 17 | 18 | assert len(global_mmcore.getLoadedDevices()) <= 1 19 | 20 | cfg.load_cfg_Button.click() 21 | 22 | assert len(global_mmcore.getLoadedDevices()) > 1 23 | -------------------------------------------------------------------------------- /tests/test_objective_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from unittest.mock import Mock, call, patch 5 | 6 | import pytest 7 | from qtpy.QtWidgets import QDialog 8 | 9 | from pymmcore_widgets._util import ComboMessageBox 10 | from pymmcore_widgets.control._objective_widget import ObjectivesWidget 11 | 12 | if TYPE_CHECKING: 13 | from pymmcore_plus import CMMCorePlus 14 | from pytestqt.qtbot import QtBot 15 | 16 | 17 | def test_objective_widget_changes_objective(global_mmcore: CMMCorePlus, qtbot: QtBot): 18 | obj_wdg = ObjectivesWidget() 19 | qtbot.addWidget(obj_wdg) 20 | 21 | start_z = 100.0 22 | global_mmcore.setPosition("Z", start_z) 23 | stage_mock = Mock() 24 | obj_wdg._mmc.events.stagePositionChanged.connect(stage_mock) 25 | 26 | px_size_mock = Mock() 27 | obj_wdg._mmc.events.pixelSizeChanged.connect(px_size_mock) 28 | 29 | assert obj_wdg._combo.currentText() == "Nikon 10X S Fluor" 30 | with pytest.raises(ValueError): 31 | obj_wdg._combo.setCurrentText("10asdfdsX") 32 | 33 | assert global_mmcore.getCurrentPixelSizeConfig() == "Res10x" 34 | 35 | new_val = "Nikon 40X Plan Fluor ELWD" 36 | with qtbot.waitSignals( 37 | [global_mmcore.events.propertyChanged, global_mmcore.events.pixelSizeChanged] 38 | ): 39 | obj_wdg._combo.setCurrentText(new_val) 40 | 41 | px_size_mock.assert_has_calls([call(0.25)]) 42 | stage_mock.assert_has_calls([call("Z", 0), call("Z", start_z)]) 43 | assert obj_wdg._combo.currentText() == new_val 44 | assert global_mmcore.getStateLabel(obj_wdg._objective_device) == new_val 45 | assert global_mmcore.getCurrentPixelSizeConfig() == "Res40x" 46 | 47 | assert global_mmcore.getPosition("Z") == start_z 48 | 49 | 50 | @patch.object(ComboMessageBox, "exec_") 51 | def test_guess_objectve(dialog_mock, global_mmcore: CMMCorePlus, qtbot: QtBot): 52 | dialog_mock.return_value = QDialog.DialogCode.Accepted 53 | with patch.object(global_mmcore, "guessObjectiveDevices") as mock: 54 | mock.return_value = ["Objective", "Obj2"] 55 | obj_wdg = ObjectivesWidget(mmcore=global_mmcore) 56 | qtbot.addWidget(obj_wdg) 57 | -------------------------------------------------------------------------------- /tests/test_presets_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from pymmcore_widgets.control._presets_widget import PresetsWidget 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from pytestqt.qtbot import QtBot 12 | 13 | 14 | def test_preset_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 15 | for group in global_mmcore.getAvailableConfigGroups(): 16 | wdg = PresetsWidget(group) 17 | qtbot.addWidget(wdg) 18 | presets = list(global_mmcore.getAvailableConfigs(group)) 19 | assert list(wdg.allowedValues()) == presets 20 | 21 | # no need testing the changes of a config group that has <= 1 item 22 | if len(presets) <= 1: 23 | return 24 | 25 | with qtbot.waitSignal(global_mmcore.events.configSet): 26 | global_mmcore.setConfig(group, presets[-1]) 27 | assert wdg.value() == presets[-1] == global_mmcore.getCurrentConfig(group) 28 | 29 | wdg.setValue(presets[0]) 30 | assert global_mmcore.getCurrentConfig(group) == presets[0] 31 | 32 | if group == "Camera": 33 | global_mmcore.setProperty("Camera", "Binning", "8") 34 | assert wdg._combo.styleSheet() == "color: magenta;" 35 | global_mmcore.setProperty("Camera", "Binning", "1") 36 | assert wdg._combo.styleSheet() == "" 37 | 38 | global_mmcore.setConfig("Camera", "HighRes") 39 | assert wdg._combo.currentText() == "HighRes" 40 | assert wdg._combo.styleSheet() == "" 41 | global_mmcore.setProperty("Camera", "Binning", "2") 42 | assert wdg._combo.currentText() == "HighRes" 43 | assert wdg._combo.styleSheet() == "color: magenta;" 44 | global_mmcore.setProperty("Camera", "BitDepth", "10") 45 | assert wdg._combo.currentText() == "MedRes" 46 | assert wdg._combo.styleSheet() == "" 47 | 48 | warning_string = ( 49 | "'test' preset is missing the following properties:" 50 | "[('Camera', 'BitDepth')]" 51 | ) 52 | with pytest.warns(UserWarning, match=warning_string): 53 | with qtbot.waitSignals([global_mmcore.events.configDefined]): 54 | global_mmcore.defineConfig( 55 | "Camera", "test", "Camera", "Binning", "4" 56 | ) 57 | assert len(wdg.allowedValues()) == 4 58 | assert "test" in wdg.allowedValues() 59 | global_mmcore.deleteConfig("Camera", "test") 60 | assert len(wdg.allowedValues()) == 3 61 | assert "test" not in wdg.allowedValues() 62 | 63 | warning_string = ( 64 | "[('Dichroic', 'Label')]are not included in the 'Camera' group " 65 | "and will not be added!" 66 | ) 67 | with pytest.warns(UserWarning, match=warning_string): 68 | with qtbot.waitSignals([global_mmcore.events.configDefined]): 69 | global_mmcore.defineConfig( 70 | "Camera", "test", "Dichroic", "Label", "400DCLP" 71 | ) 72 | assert len(wdg.allowedValues()) == 3 73 | assert "test" not in wdg.allowedValues() 74 | 75 | wdg._disconnect() 76 | # once disconnected, core changes shouldn't call out to the widget 77 | global_mmcore.setConfig(group, presets[1]) 78 | assert global_mmcore.getCurrentConfig(group) != wdg.value() 79 | 80 | global_mmcore.deleteConfigGroup("Camera") 81 | assert "Camera" not in global_mmcore.getAvailableConfigGroups() 82 | -------------------------------------------------------------------------------- /tests/test_prop_widget.py: -------------------------------------------------------------------------------- 1 | import faulthandler 2 | 3 | import pytest 4 | from pymmcore_plus import CMMCorePlus, PropertyType 5 | 6 | from pymmcore_widgets import PropertyWidget 7 | 8 | faulthandler.enable() 9 | 10 | # not sure how else to parametrize the test without instantiating here at import ... 11 | # NOTE: in the default 'MMConfig_demo.cgf', the device called 'LED' 12 | # is a mock State Device (DStateDevice) device from the 'DemoCamera DHub. 13 | # We are excluding the dev-prop 'LED-Number of positions' because 14 | # it is not an actual property of the device, but it is only used 15 | # in the micromanager "Hardwre Configuration Wizard" to set the number 16 | # of states (by default, 10) that the mock device can have. 17 | CORE = CMMCorePlus() 18 | CORE.loadSystemConfiguration() 19 | dev_props = [ 20 | (dev, prop) 21 | for dev in CORE.getLoadedDevices() 22 | for prop in CORE.getDevicePropertyNames(dev) 23 | if dev != "LED" and prop not in {"Number of positions", "Initialize"} 24 | ] 25 | 26 | 27 | def _assert_equal(a, b): 28 | try: 29 | assert float(a) == float(b) 30 | except ValueError: 31 | assert str(a) == str(b) 32 | 33 | 34 | @pytest.mark.parametrize("dev, prop", dev_props) 35 | def test_property_widget(dev, prop, qtbot) -> None: 36 | wdg = PropertyWidget(dev, prop, mmcore=CORE) 37 | qtbot.addWidget(wdg) 38 | if CORE.isPropertyReadOnly(dev, prop) or prop in ( 39 | "SimulateCrash", 40 | "Trigger", 41 | "AsyncPropertyLeader", 42 | ): 43 | return 44 | 45 | start_val = CORE.getProperty(dev, prop) 46 | _assert_equal(wdg.value(), start_val) 47 | 48 | # make sure that setting the value via the widget updates core 49 | if allowed := CORE.getAllowedPropertyValues(dev, prop): 50 | val = allowed[-1] 51 | elif CORE.getPropertyType(dev, prop) in (PropertyType.Integer, PropertyType.Float): 52 | # these are just numbers that work for the test config devices 53 | _vals = { 54 | "TestProperty": 1, 55 | "Photon Flux": 50, 56 | "TestProperty1": 0.01, 57 | "TestProperty3": 0.002, 58 | "OnCameraCCDXSize": 20, 59 | "OnCameraCCDYSize": 20, 60 | "FractionOfPixelsToDropOrSaturate": 0.05, 61 | } 62 | val = _vals.get(prop, 1) 63 | else: 64 | val = "some string" 65 | 66 | before = wdg.value() 67 | wdg.setValue(val) 68 | 69 | strict_init = hasattr(CORE, "isFeatureEnabled") and CORE.isFeatureEnabled( 70 | "StrictInitializationChecks" 71 | ) 72 | if CORE.isPropertyPreInit(dev, prop) and strict_init: 73 | # as of pymmcore 10.7.0.71.0, setting pre-init properties 74 | # after the device has been initialized does nothing. 75 | _assert_equal(wdg.value(), before) 76 | return 77 | 78 | _assert_equal(wdg.value(), val) 79 | _assert_equal(CORE.getProperty(dev, prop), val) 80 | 81 | # make sure that setting value via core updates the widget 82 | CORE.setProperty(dev, prop, start_val) 83 | _assert_equal(wdg.value(), start_val) 84 | 85 | 86 | def test_prop_widget_signals(global_mmcore: CMMCorePlus, qtbot): 87 | wdg = PropertyWidget("Camera", "Binning", connect_core=False) 88 | qtbot.addWidget(wdg) 89 | assert wdg.value() == "1" 90 | with qtbot.waitSignal(wdg.valueChanged, timeout=1000): 91 | wdg._value_widget.setValue(2) 92 | assert wdg.value() == "2" 93 | 94 | 95 | def test_reset(global_mmcore: CMMCorePlus, qtbot) -> None: 96 | wdg = PropertyWidget("Camera", "Binning", mmcore=global_mmcore) 97 | qtbot.addWidget(wdg) 98 | global_mmcore.loadSystemConfiguration() 99 | assert wdg.value() 100 | -------------------------------------------------------------------------------- /tests/test_properties_widget.py: -------------------------------------------------------------------------------- 1 | from pymmcore_plus import PropertyType 2 | from qtpy.QtWidgets import QLabel 3 | 4 | from pymmcore_widgets import PropertiesWidget, PropertyWidget 5 | 6 | 7 | def test_properties_widget(qtbot, global_mmcore): 8 | widget = PropertiesWidget( 9 | property_type={PropertyType.Integer, PropertyType.Float}, 10 | property_name_pattern="(test|camera)s?", 11 | device_type=None, 12 | device_label=None, 13 | has_limits=True, 14 | is_read_only=False, 15 | is_sequenceable=False, 16 | ) 17 | qtbot.addWidget(widget) 18 | assert widget.layout().count() == 10 19 | 20 | for i in range(widget.layout().count()): 21 | wdg = widget.layout().itemAt(i).widget() 22 | if i % 2 == 0: 23 | assert isinstance(wdg, QLabel) 24 | assert "Camera::TestProperty" in wdg.text() 25 | else: 26 | assert isinstance(wdg, PropertyWidget) 27 | assert wdg.value() == 0.0 28 | if i == 5: 29 | continue 30 | wdg.setValue(0.1) 31 | assert wdg.value() == 0.1 32 | -------------------------------------------------------------------------------- /tests/test_property_browser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from pymmcore_widgets import PropertyBrowser 6 | 7 | if TYPE_CHECKING: 8 | from pymmcore_plus import CMMCorePlus 9 | from pytestqt.qtbot import QtBot 10 | 11 | 12 | def test_prop_browser(global_mmcore: CMMCorePlus, qtbot: QtBot): 13 | pb = PropertyBrowser(mmcore=global_mmcore) 14 | qtbot.addWidget(pb) 15 | pb.show() 16 | 17 | 18 | def test_prop_browser_core_reset(global_mmcore: CMMCorePlus, qtbot: QtBot): 19 | """test that loading and resetting doesn't cause errors.""" 20 | global_mmcore.unloadAllDevices() 21 | pb = PropertyBrowser(mmcore=global_mmcore) 22 | qtbot.addWidget(pb) 23 | global_mmcore.loadSystemConfiguration() 24 | global_mmcore.reset() 25 | -------------------------------------------------------------------------------- /tests/test_save_widget.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pytestqt.qtbot import QtBot 5 | 6 | from pymmcore_widgets.mda._save_widget import ( 7 | DIRECTORY_WRITERS, 8 | FILE_NAME, 9 | OME_TIFF, 10 | OME_ZARR, 11 | SUBFOLDER, 12 | TIFF_SEQ, 13 | WRITERS, 14 | SaveGroupBox, 15 | ) 16 | 17 | 18 | def test_set_get_value(qtbot: QtBot) -> None: 19 | wdg = SaveGroupBox() 20 | qtbot.addWidget(wdg) 21 | 22 | # Can be set with a Path or a string, in which case `should_save` be set to True 23 | path = Path("/some_path/some_file") 24 | wdg.setValue(path) 25 | assert wdg.value() == { 26 | "save_dir": str(path.parent), 27 | "save_name": str(path.name), 28 | "should_save": True, 29 | "format": TIFF_SEQ, 30 | } 31 | 32 | # When setting to a file with an extension, the format is set to the known writer 33 | wdg.setValue("/some_path/some_file.ome.tif") 34 | assert wdg.value()["format"] == OME_TIFF 35 | 36 | # unrecognized extensions warn and default to TIFF_SEQ 37 | with pytest.warns( 38 | UserWarning, match=f"Invalid format '.png'. Defaulting to {TIFF_SEQ}." 39 | ): 40 | wdg.setValue("/some_path/some_file.png") 41 | assert wdg.value() == { 42 | "save_dir": str(path.parent), 43 | "save_name": "some_file.png", # note, we don't change the name 44 | "should_save": True, 45 | "format": TIFF_SEQ, 46 | } 47 | 48 | # Can be set with a dict. 49 | # note that when setting with a dict, should_save must be set explicitly 50 | wdg.setValue({"save_dir": str(path.parent), "save_name": "some_file.ome.zarr"}) 51 | assert wdg.value() == { 52 | "save_dir": str(path.parent), 53 | "save_name": "some_file.ome.zarr", 54 | "should_save": False, 55 | "format": OME_ZARR, 56 | } 57 | 58 | 59 | def test_save_box_autowriter_selection(qtbot: QtBot) -> None: 60 | """Test that setting the name to known extension changes the format""" 61 | wdg = SaveGroupBox() 62 | qtbot.addWidget(wdg) 63 | 64 | wdg.save_name.setText("name.ome.tiff") 65 | wdg.save_name.editingFinished.emit() # this only happens in the GUI 66 | assert wdg._writer_combo.currentText() == OME_TIFF 67 | 68 | # and it goes both ways 69 | wdg._writer_combo.setCurrentText(OME_ZARR) 70 | assert wdg.save_name.text() == "name.ome.zarr" 71 | 72 | 73 | @pytest.mark.parametrize("writer", WRITERS) 74 | def test_writer_combo_text_changed(qtbot: QtBot, writer: str) -> None: 75 | wdg = SaveGroupBox() 76 | qtbot.addWidget(wdg) 77 | wdg._writer_combo.setCurrentText(writer) 78 | wdg.save_name.setText("name") 79 | wdg.save_name.editingFinished.emit() 80 | 81 | assert wdg._writer_combo.currentText() == writer 82 | expected_label = SUBFOLDER if writer in DIRECTORY_WRITERS else FILE_NAME 83 | assert wdg.name_label.text() == expected_label 84 | assert wdg.save_name.text() == f"name{WRITERS[writer][0]}" 85 | -------------------------------------------------------------------------------- /tests/test_snap_button_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from qtpy.QtCore import QSize 6 | 7 | from pymmcore_widgets.control._snap_button_widget import SnapButton 8 | 9 | if TYPE_CHECKING: 10 | from pymmcore_plus import CMMCorePlus 11 | from pytestqt.qtbot import QtBot 12 | 13 | 14 | def test_snap_button_widget(qtbot: QtBot, global_mmcore: CMMCorePlus): 15 | snap_btn = SnapButton() 16 | 17 | qtbot.addWidget(snap_btn) 18 | 19 | assert snap_btn.text() == "Snap" 20 | assert snap_btn.iconSize() == QSize(30, 30) 21 | 22 | global_mmcore.startContinuousSequenceAcquisition(0) 23 | 24 | with qtbot.waitSignals( 25 | [ 26 | global_mmcore.events.sequenceAcquisitionStopped, 27 | global_mmcore.events.imageSnapped, 28 | ] 29 | ): 30 | snap_btn.click() 31 | assert not global_mmcore.isSequenceRunning() 32 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from qtpy.QtCore import QCoreApplication, QPoint, QPointF, Qt 6 | from qtpy.QtGui import QWheelEvent 7 | from qtpy.QtWidgets import QApplication, QComboBox 8 | from superqt import QDoubleSlider 9 | 10 | from pymmcore_widgets._util import NoWheelTableWidget 11 | 12 | if TYPE_CHECKING: 13 | from pymmcore_plus import CMMCorePlus 14 | from pytestqt.qtbot import QtBot 15 | 16 | 17 | WHEEL_UP = QWheelEvent( 18 | QPointF(0, 0), # pos 19 | QPointF(0, 0), # globalPos 20 | QPoint(0, 0), # pixelDelta 21 | QPoint(0, 120), # angleDelta 22 | Qt.MouseButton.NoButton, # buttons 23 | Qt.KeyboardModifier.NoModifier, # modifiers 24 | Qt.ScrollPhase.NoScrollPhase, # phase 25 | False, # inverted 26 | ) 27 | 28 | 29 | def test_no_wheel_table_scroll(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: 30 | tbl = NoWheelTableWidget() 31 | qtbot.addWidget(tbl) 32 | tbl.show() 33 | 34 | # Create enough widgets to scroll 35 | sb = tbl.verticalScrollBar() 36 | assert sb is not None 37 | while sb.maximum() == 0: 38 | new_row = tbl.rowCount() 39 | tbl.insertRow(new_row) 40 | tbl.setCellWidget(new_row, 0, QComboBox(tbl)) 41 | QApplication.processEvents() 42 | 43 | # Test Combo Box 44 | combo = QComboBox(tbl) 45 | combo.addItems(["combo0", "combo1", "combo2"]) 46 | combo.setCurrentIndex(1) 47 | combo_row = tbl.rowCount() 48 | tbl.insertRow(combo_row) 49 | tbl.setCellWidget(combo_row, 0, combo) 50 | 51 | sb.setValue(sb.maximum()) 52 | # Synchronous event emission and allows us to pass through the event filter 53 | QCoreApplication.sendEvent(combo, WHEEL_UP) 54 | # Assert the table widget scrolled but the combo didn't change 55 | assert sb.value() < sb.maximum() 56 | assert combo.currentIndex() == 1 57 | 58 | # Test Slider 59 | slider = QDoubleSlider(tbl) 60 | slider.setRange(0, 1) 61 | slider.setValue(0) 62 | slider_row = tbl.rowCount() 63 | tbl.insertRow(slider_row) 64 | tbl.setCellWidget(slider_row, 0, slider) 65 | 66 | sb.setValue(sb.maximum()) 67 | # Synchronous event emission and allows us to pass through the event filter 68 | QCoreApplication.sendEvent(slider, WHEEL_UP) 69 | # Assert the table widget scrolled but the slider didn't change 70 | assert sb.value() < sb.maximum() 71 | assert slider.value() == 0 72 | 73 | # I can't know how to hear any more about tables 74 | tbl.close() 75 | -------------------------------------------------------------------------------- /tests/useq_widgets/test_plate_widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | import pytest 6 | import qtpy 7 | import useq 8 | from qtpy.QtCore import Qt 9 | from qtpy.QtGui import QMouseEvent 10 | 11 | from pymmcore_widgets.useq_widgets import WellPlateWidget 12 | 13 | if TYPE_CHECKING: 14 | from pytestqt.qtbot import QtBot 15 | 16 | WELL_96 = useq.WellPlate.from_str("96-well") 17 | CUSTOM_PLATE = useq.WellPlate( 18 | name="custom", 19 | rows=8, 20 | columns=12, 21 | circular_wells=False, 22 | well_size=(13, 10), 23 | well_spacing=(18, 18), 24 | ) 25 | 26 | BASIC_PLAN = useq.WellPlatePlan( 27 | plate="96-well", 28 | a1_center_xy=(0, 0), 29 | selected_wells=slice(0, 8, 2), 30 | ) 31 | 32 | ROTATED_PLAN = BASIC_PLAN.model_copy(update={"rotation": 10}) 33 | 34 | 35 | @pytest.mark.parametrize("plan", [BASIC_PLAN, ROTATED_PLAN, CUSTOM_PLATE]) 36 | def test_plate_widget(qtbot: QtBot, plan: Any) -> None: 37 | wdg = WellPlateWidget(plan) 38 | qtbot.addWidget(wdg) 39 | wdg.show() 40 | val = wdg.value() 41 | if isinstance(plan, useq.WellPlate): 42 | val = val.plate # type: ignore 43 | assert val == plan 44 | 45 | 46 | def test_plate_widget_selection(qtbot: QtBot) -> None: 47 | wdg = WellPlateWidget() 48 | qtbot.addWidget(wdg) 49 | wdg.show() 50 | 51 | # Ensure that if no plate is provided when instantiating the widget, the currently 52 | # selected plate in the combobox is used. 53 | assert wdg._view.scene().items() 54 | 55 | wdg.plate_name.setCurrentText("96-well") 56 | wdg.setCurrentSelection((slice(0, 4, 2), (1, 2))) 57 | selection = wdg.currentSelection() 58 | assert selection == ((0, 1), (0, 2), (2, 1), (2, 2)) 59 | 60 | 61 | @pytest.mark.skipif(qtpy.QT5, reason="QMouseEvent API changed") 62 | def test_plate_mouse_press(qtbot: QtBot) -> None: 63 | wdg = WellPlateWidget() 64 | qtbot.addWidget(wdg) 65 | wdg.show() 66 | wdg.plate_name.setCurrentText("96-well") 67 | 68 | # press 69 | assert wdg._view._pressed_item is None 70 | assert not wdg._view._selected_items 71 | event = QMouseEvent( 72 | QMouseEvent.Type.MouseButtonPress, 73 | wdg.rect().translated(10, 0).center().toPointF(), 74 | wdg.rect().translated(10, 0).center().toPointF(), 75 | Qt.MouseButton.LeftButton, 76 | Qt.MouseButton.LeftButton, 77 | Qt.KeyboardModifier.NoModifier, 78 | ) 79 | wdg._view.mousePressEvent(event) 80 | 81 | assert wdg._view._pressed_item is not None 82 | 83 | # release 84 | event = QMouseEvent( 85 | QMouseEvent.Type.MouseButtonRelease, 86 | wdg.rect().translated(10, 0).center().toPointF(), 87 | wdg.rect().translated(10, 0).center().toPointF(), 88 | Qt.MouseButton.LeftButton, 89 | Qt.MouseButton.LeftButton, 90 | Qt.KeyboardModifier.NoModifier, 91 | ) 92 | wdg._view.mouseReleaseEvent(event) 93 | 94 | assert wdg._view._pressed_item is None 95 | assert len(wdg._view._selected_items) == 1 96 | 97 | # simulate rubber band on full widget, should select all 98 | with qtbot.waitSignal(wdg._view.selectionChanged): 99 | wdg._view._on_rubber_band_changed(wdg.rect()) 100 | assert len(wdg._view._selected_items) == 96 101 | 102 | # simulate opt-click rubber band on full widget, should clear selection 103 | event = QMouseEvent( 104 | QMouseEvent.Type.MouseButtonPress, 105 | wdg.rect().translated(10, 0).center().toPointF(), 106 | wdg.rect().translated(10, 0).center().toPointF(), 107 | Qt.MouseButton.LeftButton, 108 | Qt.MouseButton.LeftButton, 109 | Qt.KeyboardModifier.AltModifier, 110 | ) 111 | wdg._view.mousePressEvent(event) 112 | 113 | # simulate rubber band on full widget 114 | with qtbot.waitSignal(wdg._view.selectionChanged): 115 | wdg._view._on_rubber_band_changed(wdg.rect()) 116 | assert len(wdg._view._selected_items) == 0 117 | --------------------------------------------------------------------------------