├── .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 | [](https://github.com/pymmcore-plus/pymmcore-widgets/raw/main/LICENSE)
4 | [](https://python.org)
5 | [](https://pypi.org/project/pymmcore-widgets)
6 | [](https://anaconda.org/conda-forge/pymmcore-widgets)
7 | [](https://github.com/pymmcore-plus/pymmcore-widgets/actions/workflows/ci.yml)
8 | [](https://pymmcore-plus.github.io/pymmcore-widgets/)
9 | [](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 |
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 | {{ 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 | 
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 | 
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 |
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 |
22 |
--------------------------------------------------------------------------------
/src/pymmcore_widgets/hcs/icons/circle-edges.svg:
--------------------------------------------------------------------------------
1 |
2 |
34 |
--------------------------------------------------------------------------------
/src/pymmcore_widgets/hcs/icons/square-center.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/pymmcore_widgets/hcs/icons/square-edges.svg:
--------------------------------------------------------------------------------
1 |
2 |
40 |
--------------------------------------------------------------------------------
/src/pymmcore_widgets/hcs/icons/square-vertices.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
--------------------------------------------------------------------------------