diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..82e5e6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Samuel Holland + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..220b8f7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,36 @@ +pickmix bundles the following third-party material. + +add.svg + Material Symbols "add_circle" icon + Copyright (c) Google LLC + Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory) + Source: https://github.com/google/material-design-icons + Modifications: fill colour set to #ffffff + +edit.svg + Material Symbols "edit" icon + Copyright (c) Google LLC + Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory) + Source: https://github.com/google/material-design-icons + Modifications: fill colour set to #ffffff + +help.svg + Material Symbols "help" icon + Copyright (c) Google LLC + Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory) + Source: https://github.com/google/material-design-icons + Modifications: fill colour set to #8a8a8a + +visibility.svg + Material Symbols "visibiility" icon + Copyright (c) Google LLC + Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory) + Source: https://github.com/google/material-design-icons + Modifications: fill colour set to #8a8a8a + +visibility_off.svg + Material Symbols "visibility_off" icon + Copyright (c) Google LLC + Licensed under the Apache License, Version 2.0 (see src/pickmix/gui/icons/LICENSE in this directory) + Source: https://github.com/google/material-design-icons + Modifications: fill colour set to #8a8a8a \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c5eb67d..5abb4ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pickmix" -version = "0.1.0" +version = "0.2.0" description = "Multi-kind particle picking GUI for cryo-ET sub-tomogram averaging workflows." readme = "README.md" authors = [ diff --git a/src/pickmix/config.py b/src/pickmix/config.py index b1ad4dc..9fff7aa 100644 --- a/src/pickmix/config.py +++ b/src/pickmix/config.py @@ -8,45 +8,48 @@ from pydantic import BaseModel, Field, field_validator, model_validator from typing import Literal -# Kind: one class of particle +# HOTKEYS: number of hotkeys, keys 1 to 9 +HOTKEYS: int = 9 + +# Kind: one class of particle; title and description feed the kind dialog class Kind(BaseModel): # displayed marker diameter, Angstrom - marker_a: float = Field(gt=0) + marker_a: float = Field(gt=0, title='Marker diameter (Å)', description='Diameter of the marker drawn on a pick.') # extraction box edge, Angstrom; 0 takes the box from the estimator - box_a: float = Field(default=0.0, ge=0) + box_a: float = Field(default=0.0, ge=0, title='Box edge (Å)', description='Extraction box edge. 0 takes the box from the estimator.') # name in the estimator registry - estimator: str = 'none' + estimator: str = Field(default='none', title='Estimator', description='Estimator that fits each pick of this kind.') # any Qt colour name or #rrggbb - colour: str = 'yellow' + colour: str = Field(default='blue', title='Colour', description='Marker colour: a name or hex code (#rrggbb).') # kind whose nearest estimate supplies membrane-normal geometry - parent: str | None = None + parent: str | None = Field(default=None, title='Parent', description='Kind whose nearest estimate supplies membrane-normal geometry.') # how far a pick may sit from the parent's surface and still count as on it - parent_tolerance_a: float = Field(default=100.0, gt=0) + parent_tolerance_a: float = Field(default=100.0, gt=0, title='Parent tolerance (Å)', description='How far a pick may sit from the parent surface and still count as on it.') # written as rlnAngleTiltPrior when set - tilt_prior_deg: float | None = None + tilt_prior_deg: float | None = Field(default=None, title='Tilt prior (°)', description='Written as rlnAngleTiltPrior when set, and left out otherwise.') # pixel size of the RELION extraction in Angstrom per pixel - extract_angpix: float = Field(default=10.68, gt=0) + extract_angpix: float = Field(default=10.68, gt=0, title='Extract pixel size (Å/px)', description='Pixel size of the RELION extraction in Å per pixel.') -# RingParams: settings of the ring estimator, all lengths in Angstrom +# RingParams: settings of the ring estimator, all lengths in Angstrom; title and description feed the settings form class RingParams(BaseModel): # cage radius sweep and ring width - radius_range_a: tuple[float, float] = (150.0, 500.0) - radius_step_a: float = 25.0 - ring_sigma_a: float = 30.0 + radius_range_a: tuple[float, float] = Field(default=(150.0, 500.0), title='Radius range (Å)', description='Smallest and largest cage radius tried.') + radius_step_a: float = Field(default=25.0, title='Radius step (Å)', description='Spacing of the radii tried across the range.') + ring_sigma_a: float = Field(default=30.0, title='Ring width σ (Å)', description='Width of the dark ring template.') # background flattening and light smoothing of each projection - highpass_sigma_a: float = 500.0 - smooth_sigma_a: float = 8.0 + highpass_sigma_a: float = Field(default=500.0, title='High-pass σ (Å)', description='Gaussian width removed from each projection to flatten the background.') + smooth_sigma_a: float = Field(default=8.0, title='Smoothing σ (Å)', description='Light Gaussian smoothing of each projection.') # ring half-width and the background annulus as multiples of the ring radius - ring_halfwidth_a: float = 40.0 - bg_range: tuple[float, float] = (1.3, 1.8) + ring_halfwidth_a: float = Field(default=40.0, title='Ring half-width (Å)', description='Half-width of the ring used to score its contrast.') + bg_range: tuple[float, float] = Field(default=(1.3, 1.8), title='Background annulus (× radius)', description='Inner and outer edge of the background annulus, as multiples of the ring radius.') # z slab grown up to walk_max_half_a, stopping after walk_patience steps without a better SNR - walk_max_half_a: float = 400.0 - walk_patience: int = 3 + walk_max_half_a: float = Field(default=400.0, title='Max slab half-thickness (Å)', description='The z slab grows up to this half-thickness.') + walk_patience: int = Field(default=3, title='Walk patience (steps)', description='Growth stops after this many steps without a better SNR.') # how far the centre may move from the pick in xy, and the slab centre in z - locate_range_a: float = 200.0 - z_shift_a: float = 100.0 + locate_range_a: float = Field(default=200.0, title='Locate range (Å)', description='How far the ring centre may move from the pick in xy.') + z_shift_a: float = Field(default=100.0, title='Z shift (Å)', description='How far the slab centre may move from the pick in z.') # box edge as a multiple of the ring diameter when the kind takes its box from the estimator - box_margin: float = 1.25 + box_margin: float = Field(default=1.25, title='Box margin (× diameter)', description='Box edge as a multiple of the ring diameter, for kinds that take their box from the estimator.') # _default_kinds: the single cage kind used when a config has no kinds table def _default_kinds() -> dict[str, Kind]: @@ -66,24 +69,40 @@ class Config(BaseModel): # volume read for display, estimation and extraction; falls back to raw when a tomogram has no denoised pair source: Literal['raw', 'denoised'] = 'denoised' ring: RingParams = Field(default_factory=RingParams) - # table order sets the 1 to 9 hotkeys kinds: dict[str, Kind] = Field(default_factory=_default_kinds) + # kind chosen by each of the keys 1 to 9, '' for an unused key + hotkeys: list[str] = Field(default_factory=list) # replace: validated copy with some fields swapped def replace(self, **changes: object) -> 'Config': + if 'kinds' in changes and 'hotkeys' not in changes: + changes = {**changes, 'hotkeys': []} return Config.model_validate({**self.model_dump(), **changes}) # _names: kind names become folder names, so keep them plain @field_validator('kinds') @classmethod def _names(cls, kinds: dict[str, Kind]) -> dict[str, Kind]: - if not kinds: - raise ValueError('at least one kind is needed') for name in kinds: if not re.fullmatch(r'[A-Za-z0-9_]+', name): raise ValueError(f'kind name {name!r} may only use letters, digits and underscores') return kinds + # _fill_hotkeys: fill the default, pad to nine slots, and check each names a known kind and is used once + @model_validator(mode='after') + def _fill_hotkeys(self) -> 'Config': + keys = self.hotkeys or [*self.kinds][:HOTKEYS] + if len(keys) > HOTKEYS: + raise ValueError(f'at most {HOTKEYS} hotkeys, got {len(keys)}') + used = [k for k in keys if k] + for name in used: + if name not in self.kinds: + raise ValueError(f'hotkey names unknown kind {name!r}') + if len(set(used)) != len(used): + raise ValueError('a kind may only be on one hotkey') + self.hotkeys = [*keys, *[''] * (HOTKEYS - len(keys))] + return self + # _relations: parents must exist and form no cycle, and a passthrough kind needs a box @model_validator(mode='after') def _relations(self) -> 'Config': diff --git a/src/pickmix/estimates.py b/src/pickmix/estimates.py index b903fd6..aca14f6 100644 --- a/src/pickmix/estimates.py +++ b/src/pickmix/estimates.py @@ -74,6 +74,11 @@ def put(self, pick: Pick, fit: Fit, digest: str) -> None: for listener in self.listeners: listener() + # rename_kind: keep the fits of a renamed kind; written with the next estimate, and rows that never match a pick are dropped then + def rename_kind(self, old: str, new: str) -> None: + with self._lock: + self._rows = {(_key(r.tomogram, new, r.pick_x, r.pick_y, r.pick_z) if r.kind == old else k): r.model_copy(update={'kind': new}) if r.kind == old else r for k, r in self._rows.items()} + # _save: write the CSV atomically, keeping only rows that belong to a pick now def _save(self) -> None: live = {_key(p.tomogram, p.kind, p.x, p.y, p.z) for p in self.picks.all()} diff --git a/src/pickmix/gui/app.py b/src/pickmix/gui/app.py index 55a4e5b..b1614fb 100644 --- a/src/pickmix/gui/app.py +++ b/src/pickmix/gui/app.py @@ -8,10 +8,12 @@ # Import internal pickmix objects from pickmix.gui.main_window import MainWindow +from pickmix.gui.style import standardise # run: show the main window, opening the config.toml in args if one is given, and return the exit code def run(args: list[str]) -> int: app = QApplication.instance() or QApplication(['pickmix']) + standardise(app) window = MainWindow() window.show() if args: diff --git a/src/pickmix/gui/average_pane.py b/src/pickmix/gui/average_pane.py index 138d804..94b37f9 100644 --- a/src/pickmix/gui/average_pane.py +++ b/src/pickmix/gui/average_pane.py @@ -4,6 +4,7 @@ # Import external dependencies from PySide6.QtCore import Qt +from PySide6.QtGui import QShowEvent from PySide6.QtWidgets import QComboBox, QGroupBox, QHBoxLayout, QLabel, QListWidget, QPushButton, QVBoxLayout # Import internal pickmix objects @@ -11,6 +12,7 @@ from pickmix.estimates import EstimateService from pickmix.export import Item from pickmix.gui.images import to_pixmap +from pickmix.gui.style import tidy from pickmix.gui.workers import run_in_background from pickmix.model import Session @@ -26,9 +28,7 @@ def __init__(self) -> None: self.service: EstimateService | None = None self.result: AverageResult | None = None self.kind_box = QComboBox() - self.tomo_list = QListWidget() - self.tomo_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection) - self.tomo_list.setMaximumHeight(110) + self.summary = QLabel() self.run_button = QPushButton('Run') self.status = QLabel() self.mean_label = QLabel('mean') @@ -39,20 +39,23 @@ def __init__(self) -> None: label.setStyleSheet('background: #141414; color: #888') self.results_list = QListWidget() self.results_list.setMaximumWidth(220) - left = QVBoxLayout() - left.addWidget(QLabel('Kind')) - left.addWidget(self.kind_box) - left.addWidget(QLabel('Tomograms with picks')) - left.addWidget(self.tomo_list) - left.addWidget(self.run_button) - left.addWidget(self.status) - left.addStretch() - body = QHBoxLayout(self) - body.addLayout(left) - body.addWidget(self.mean_label) - body.addWidget(self.montage_label) - body.addWidget(self.results_list) - self.kind_box.currentTextChanged.connect(self._fill_tomograms) + # kind, summary and console above the images + controls = QHBoxLayout() + controls.addWidget(QLabel('Kind')) + controls.addWidget(self.kind_box) + controls.addWidget(self.run_button) + controls.addWidget(self.summary, 1) + images = QHBoxLayout() + images.addWidget(self.mean_label) + images.addWidget(self.montage_label) + images.addWidget(self.results_list) + images.addStretch() + body = QVBoxLayout(self) + tidy(body) + body.addLayout(controls) + body.addWidget(self.status) + body.addLayout(images) + self.kind_box.currentTextChanged.connect(self._update_summary) self.run_button.clicked.connect(self.run) self.results_list.currentRowChanged.connect(self._show_row) @@ -70,17 +73,16 @@ def refresh_kinds(self) -> None: self.kind_box.addItems(list(self.session.project.config.kinds)) self.kind_box.setCurrentText(current) self.kind_box.blockSignals(False) - self._fill_tomograms() + self._update_summary() - # items: current fits of the chosen kind in the selected tomograms + # items: current fits of the chosen kind in every tomogram def items(self) -> list[Item]: if self.session is None or self.service is None: return [] - chosen = {i.text() for i in self.tomo_list.selectedItems()} out = [] for pick in self.session.store.all(self.kind_box.currentText()): fit = self.service.store.get(pick) - if fit is not None and pick.tomogram in chosen: + if fit is not None: out.append((pick, fit)) return out @@ -88,6 +90,7 @@ def items(self) -> list[Item]: def run(self) -> None: if self.session is None: return + self._update_summary() items, name = self.items(), self.kind_box.currentText() if not items: self.status.setText('No picks with a current estimate in the selected tomograms') @@ -97,16 +100,19 @@ def run(self) -> None: self.status.setText(f'Averaging {len(items)} picks...') run_in_background(lambda: average_kind(name, config.kinds[name], items, tomos, config), self._done, self._failed) - # _fill_tomograms: tomograms that have picks of the chosen kind, all selected - def _fill_tomograms(self) -> None: - self.tomo_list.clear() - if self.session is None: + # _update_summary: total picks, tomograms with picks and picks per tomogram for the chosen kind + def _update_summary(self) -> None: + picks = self.session.store.all(self.kind_box.currentText()) if self.session is not None else [] + tomograms = len({p.tomogram for p in picks}) + if not tomograms: + self.summary.setText('No picks of this kind') return - kind = self.kind_box.currentText() - for name in self.session.names: - if self.session.store.count(name, kind): - self.tomo_list.addItem(name) - self.tomo_list.selectAll() + self.summary.setText(f'{len(picks)} picks in {tomograms} tomograms ({len(picks) / tomograms:.1f} per tomogram)') + + # showEvent: picks change on other tabs, so refresh the summary whenever this pane appears + def showEvent(self, event: QShowEvent) -> None: + self._update_summary() + super().showEvent(event) # _done: show the overall mean and list what can be looked at def _done(self, result: AverageResult) -> None: diff --git a/src/pickmix/gui/canvas.py b/src/pickmix/gui/canvas.py index 2abd267..4fe512a 100644 --- a/src/pickmix/gui/canvas.py +++ b/src/pickmix/gui/canvas.py @@ -183,7 +183,7 @@ def fit(self) -> None: # mousePressEvent: pan, select or drag a marker, or request a new pick def mousePressEvent(self, event: QMouseEvent) -> None: pos = self.mapToScene(event.position().toPoint()) - if event.button() == Qt.MouseButton.MiddleButton: + if event.button() == Qt.MouseButton.RightButton: self._pan = event.position() self.setCursor(Qt.CursorShape.ClosedHandCursor) elif event.button() == Qt.MouseButton.LeftButton: @@ -216,7 +216,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: # mouseReleaseEvent: finish a pan, or finish a marker press as a click or a move def mouseReleaseEvent(self, event: QMouseEvent) -> None: - if event.button() == Qt.MouseButton.MiddleButton and self._pan is not None: + if event.button() == Qt.MouseButton.RightButton and self._pan is not None: self._pan = None self.unsetCursor() elif event.button() == Qt.MouseButton.LeftButton and self._drag is not None: diff --git a/src/pickmix/gui/collapsible.py b/src/pickmix/gui/collapsible.py new file mode 100644 index 0000000..833b3b3 --- /dev/null +++ b/src/pickmix/gui/collapsible.py @@ -0,0 +1,35 @@ +''' +pickmix: collapsible section widget +''' + +# Import external dependencies +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QToolButton, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.gui.style import body_pt + +# Collapsible: a header button with an arrow that shows or hides a widget +class Collapsible(QWidget): + # __init__: header over content, collapsed unless expanded + def __init__(self, text: str, content: QWidget, *, expanded: bool = False) -> None: + super().__init__() + self.content = content + self.toggle = QToolButton() + self.toggle.setText(text) + self.toggle.setCheckable(True) + # flat bold text and arrow: no button frame, no highlight when checked + self.toggle.setStyleSheet(f'QToolButton {{ border: none; background: transparent; font-weight: bold; font-size: {body_pt()}pt; }}') + self.toggle.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.toggle, alignment=Qt.AlignmentFlag.AlignLeft) + layout.addWidget(content) + self.toggle.toggled.connect(self._show) + self.toggle.setChecked(expanded) + self._show(expanded) + + # _show: arrow and content follow the toggle + def _show(self, expanded: bool) -> None: + self.toggle.setArrowType(Qt.ArrowType.DownArrow if expanded else Qt.ArrowType.RightArrow) + self.content.setVisible(expanded) diff --git a/src/pickmix/gui/icons/LICENSE b/src/pickmix/gui/icons/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/src/pickmix/gui/icons/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/src/pickmix/gui/icons/add.svg b/src/pickmix/gui/icons/add.svg new file mode 100644 index 0000000..d0e7755 --- /dev/null +++ b/src/pickmix/gui/icons/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/edit.svg b/src/pickmix/gui/icons/edit.svg new file mode 100644 index 0000000..b777287 --- /dev/null +++ b/src/pickmix/gui/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/help.svg b/src/pickmix/gui/icons/help.svg new file mode 100644 index 0000000..e863bd6 --- /dev/null +++ b/src/pickmix/gui/icons/help.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/visibility.svg b/src/pickmix/gui/icons/visibility.svg new file mode 100644 index 0000000..8ca90ff --- /dev/null +++ b/src/pickmix/gui/icons/visibility.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/icons/visibility_off.svg b/src/pickmix/gui/icons/visibility_off.svg new file mode 100644 index 0000000..6a20472 --- /dev/null +++ b/src/pickmix/gui/icons/visibility_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pickmix/gui/kind_dialog.py b/src/pickmix/gui/kind_dialog.py new file mode 100644 index 0000000..0622627 --- /dev/null +++ b/src/pickmix/gui/kind_dialog.py @@ -0,0 +1,151 @@ +''' +pickmix: dialog for adding a kind or editing one +''' + +# Import external dependencies +import re +from collections.abc import Callable +from pydantic import ValidationError +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QGroupBox, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, +) + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.estimate.registry import ESTIMATORS +from pickmix.gui.style import form_label, tidy + +# DELETED: result code of the dialog when the user chose to delete the kind +DELETED: int = 2 + +# _TEXT: label and help of the rows that are not Kind fields +_TEXT: dict[str, tuple[str, str]] = { + 'name': ('Name', 'Letters, digits and underscores. Changing it renames the kind and its picks.'), +} + +# NO_PARENT: text of the parent choice that means no parent +NO_PARENT: str = '(none)' + +# KindDialog: sections for the kind's identity, size, estimation and extraction, each row labelled "Title (units) (?):" +class KindDialog(QDialog): + # __init__: build the form; name and kind are None when adding, others are the kinds besides this one (so the parent choices and the names this kind cannot take), and check returns why a name and kind cannot be saved, or None + def __init__( + self, parent: QWidget | None, name: str | None, kind: Kind | None, others: list[str], + check: Callable[[str, Kind], str | None], + ) -> None: + super().__init__(parent) + self._check = check + self._others = others + self.labels: dict[str, QLabel] = {} + self.adding = name is None + kind = kind or Kind(marker_a=100.0, box_a=100.0) + self.setWindowTitle('Add kind' if self.adding else f'Edit kind {name}') + self.name_edit = QLineEdit(name or '') + self.colour_edit = QLineEdit(kind.colour) + choose = QPushButton('Choose...') + choose.clicked.connect(self._choose_colour) + self.marker_spin = _spin(kind.marker_a, minimum=0.01) + self.box_spin = _spin(kind.box_a) + self.estimator_box = QComboBox() + # keep a name the registry does not know yet, so editing never rewrites it + self.estimator_box.addItems(sorted({*ESTIMATORS, kind.estimator})) + self.estimator_box.setCurrentText(kind.estimator) + self.parent_box = QComboBox() + self.parent_box.addItems([NO_PARENT, *others]) + self.parent_box.setCurrentText(kind.parent or NO_PARENT) + self.tolerance_spin = _spin(kind.parent_tolerance_a, minimum=0.01) + self.tilt_box = QCheckBox('Write tilt prior') + self.tilt_spin = _spin(kind.tilt_prior_deg or 0.0, minimum=-360.0, maximum=360.0) + self.tilt_box.setChecked(kind.tilt_prior_deg is not None) + self.tilt_spin.setEnabled(self.tilt_box.isChecked()) + self.tilt_box.toggled.connect(self.tilt_spin.setEnabled) + self.angpix_spin = _spin(kind.extract_angpix, minimum=0.01) + self.error = QLabel() + self.error.setStyleSheet('color: #b00020') + self.error.setWordWrap(True) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + self.delete_button = QPushButton('Delete kind') + self.delete_button.setVisible(not self.adding) + self.delete_button.clicked.connect(lambda: self.done(DELETED)) + colour_row = QHBoxLayout() + colour_row.addWidget(self.colour_edit) + colour_row.addWidget(choose) + tilt_row = QHBoxLayout() + tilt_row.addWidget(self.tilt_box) + tilt_row.addWidget(self.tilt_spin) + footer = QHBoxLayout() + footer.addWidget(self.delete_button) + footer.addStretch() + footer.addWidget(buttons) + layout = QVBoxLayout(self) + tidy(layout) + layout.addWidget(self._section('General', [('name', self.name_edit), ('colour', colour_row)])) + layout.addWidget(self._section('Size', [('marker_a', self.marker_spin), ('box_a', self.box_spin)])) + layout.addWidget(self._section('Estimation', [ + ('estimator', self.estimator_box), ('parent', self.parent_box), ('parent_tolerance_a', self.tolerance_spin), + ])) + layout.addWidget(self._section('Extraction', [('tilt_prior_deg', tilt_row), ('extract_angpix', self.angpix_spin)])) + layout.addWidget(self.error) + layout.addLayout(footer) + + # values: the name and Kind as entered; ValidationError if a field is out of range + def values(self) -> tuple[str, Kind]: + parent = self.parent_box.currentText() + return self.name_edit.text().strip(), Kind( + marker_a=self.marker_spin.value(), box_a=self.box_spin.value(), estimator=self.estimator_box.currentText(), + colour=self.colour_edit.text().strip(), parent=None if parent == NO_PARENT else parent, + parent_tolerance_a=self.tolerance_spin.value(), + tilt_prior_deg=self.tilt_spin.value() if self.tilt_box.isChecked() else None, + extract_angpix=self.angpix_spin.value(), + ) + + # accept: close only when the entries make a kind the caller can save; otherwise say why + def accept(self) -> None: + message = self._problem() + self.error.setText(message or '') + if message is None: + super().accept() + + # _problem: why the entries cannot be saved, or None + def _problem(self) -> str | None: + name = self.name_edit.text().strip() + if not re.fullmatch(r'[A-Za-z0-9_]+', name): + return 'Name may only use letters, digits and underscores.' + if name in self._others: + return f'A kind called {name!r} already exists.' + if not QColor(self.colour_edit.text().strip()).isValid(): + return f'{self.colour_edit.text().strip()!r} is not a colour.' + try: + _, kind = self.values() + except ValidationError as e: + return e.errors()[0]['msg'] + return self._check(name, kind) + + # _section: a titled group box of rows, each labelled from its key's title and description + def _section(self, title: str, rows: list[tuple[str, QWidget | QHBoxLayout]]) -> QGroupBox: + box = QGroupBox(title) + form = QFormLayout(box) + for key, field in rows: + label, tip = _TEXT[key] if key in _TEXT else (Kind.model_fields[key].title or key, Kind.model_fields[key].description or '') + head = form_label(label, tip) + self.labels[key] = head.findChild(QLabel) + form.addRow(head, field) + return box + + # _choose_colour: pick a colour in the system colour dialog + def _choose_colour(self) -> None: + colour = QColorDialog.getColor(QColor(self.colour_edit.text().strip()), self) + if colour.isValid(): + self.colour_edit.setText(colour.name()) + +# _spin: a float spin box with two decimals +def _spin(value: float, *, minimum: float = 0.0, maximum: float = 1_000_000.0) -> QDoubleSpinBox: + box = QDoubleSpinBox() + box.setRange(minimum, maximum) + box.setDecimals(2) + box.setValue(value) + return box diff --git a/src/pickmix/gui/kinds_tab.py b/src/pickmix/gui/kinds_tab.py index 12a749e..e17a5ee 100644 --- a/src/pickmix/gui/kinds_tab.py +++ b/src/pickmix/gui/kinds_tab.py @@ -1,176 +1,269 @@ ''' -pickmix: Kinds tab, editing the kinds table and saving it to config.toml +pickmix: Kinds tab, listing the kinds, choosing hotkeys and editing both in config.toml ''' # Import external dependencies +import html from pydantic import ValidationError -from PySide6.QtCore import Signal -from PySide6.QtGui import QColor +from PySide6.QtCore import QEvent, QObject, Qt, Signal from PySide6.QtWidgets import ( - QComboBox, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QComboBox, QFormLayout, QGridLayout, QHBoxLayout, QLabel, QMessageBox, + QPushButton, QScrollArea, QToolButton, QVBoxLayout, QWidget, ) # Import internal pickmix objects -from pickmix.config import Kind -from pickmix.estimate.registry import ESTIMATORS +from pickmix.config import HOTKEYS, Kind +from pickmix.estimates import EstimateService from pickmix.gui.average_pane import AveragePane +from pickmix.gui.kind_dialog import DELETED, KindDialog +from pickmix.gui.style import heading, icon, tidy +from pickmix.model import Session from pickmix.project import Project -# _COLUMNS: kinds table headings; the first is the kind name, the rest are Kind fields in order -_COLUMNS: list[str] = [ - 'Name', 'Marker (A)', 'Box (A, 0 = estimator)', 'Estimator', 'Colour', 'Parent', - 'Parent tolerance (A)', 'Tilt prior (deg)', 'Extract A/px', -] +# NO_KIND: text of the hotkey choice that leaves a key unused +NO_KIND: str = '(none)' -# _ESTIMATOR_COL, _COLOUR_COL: columns needing more than plain text -_ESTIMATOR_COL: int = 3 -_COLOUR_COL: int = 4 +# _CARD_WIDTH: narrowest a kind's block gets, in pixels, before the grid drops a column +_CARD_WIDTH: int = 260 -# KindsTab: one row per kind, row order is hotkey order +# KindsTab: the kinds as a list with an edit button each, and one dropdown per hotkey class KindsTab(QWidget): - # config_saved: the kinds were written to config.toml + # config_saved: the kinds or hotkeys were written to config.toml config_saved = Signal() - # __init__: build the layout + # __init__: build the Kinds and Hotkeys panes over the Average pane def __init__(self) -> None: super().__init__() self.project: Project | None = None - self.table = QTableWidget(0, len(_COLUMNS)) - self.table.setHorizontalHeaderLabels(_COLUMNS) - self.table.horizontalHeader().setStretchLastSection(True) - self.table.itemChanged.connect(self._paint) + self.session: Session | None = None + self.service: EstimateService | None = None + self._loading = False + self.details: dict[str, QLabel] = {} + self.edit_buttons: dict[str, QToolButton] = {} + self.add_button = QPushButton('Add') + self.add_button.clicked.connect(self.add_kind) + self.cards: list[QWidget] = [] + self._columns = 0 + self.grid = QGridLayout() + self.grid.setAlignment(Qt.AlignmentFlag.AlignTop) + holder = QWidget() + holder.setLayout(self.grid) + self.scroll = QScrollArea() + self.scroll.setWidgetResizable(True) + self.scroll.setWidget(holder) + self.scroll.setMinimumHeight(220) + self.scroll.viewport().installEventFilter(self) + head = QHBoxLayout() + head.addWidget(heading('Kinds')) + head.addStretch() + head.addWidget(self.add_button) + kinds_pane = QVBoxLayout() + kinds_pane.addLayout(head) + kinds_pane.addWidget(self.scroll, 1) + self.hotkey_boxes = [QComboBox() for _ in range(HOTKEYS)] + keys = QFormLayout() + for i, box in enumerate(self.hotkey_boxes): + keys.addRow(str(i + 1), box) + box.currentTextChanged.connect(lambda text, i=i: self._hotkey_changed(i, text)) + hotkeys_pane = QVBoxLayout() + hotkeys_pane.addWidget(heading('Hotkeys')) + hotkeys_pane.addLayout(keys) + hotkeys_pane.addStretch() + top = QHBoxLayout() + top.addLayout(kinds_pane, 2) + top.addLayout(hotkeys_pane, 1) self.status = QLabel() - buttons = QHBoxLayout() - for text, slot in ( - ('Add', self.add_kind), ('Remove', self.remove_selected), ('Up', lambda: self._move(-1)), - ('Down', lambda: self._move(1)), ('Save', self.save), - ): - button = QPushButton(text) - button.clicked.connect(slot) - buttons.addWidget(button) - buttons.addStretch() self.average = AveragePane() layout = QVBoxLayout(self) - layout.addWidget(QLabel('Row order sets the 1 to 9 hotkeys in the Pick tab.')) - layout.addWidget(self.table) - layout.addLayout(buttons) + tidy(layout) + layout.addLayout(top, 1) layout.addWidget(self.status) layout.addWidget(self.average) - # set_project: show the kinds of a project + # set_project: show the kinds and hotkeys of a project def set_project(self, project: Project) -> None: self.project = project - self.table.setRowCount(0) - for name, kind in project.config.kinds.items(): - self._write_row(self.table.rowCount(), _to_cells(name, kind)) self.status.setText('') + self._show() - # add_kind: append a passthrough kind with placeholder values for the user to edit + # set_session: the picks and estimates that follow a kind when it is renamed or deleted, or none; the Average pane uses them too + def set_session(self, session: Session | None, service: EstimateService | None) -> None: + self.session, self.service = session, service + self.average.set_session(session, service) + + # eventFilter: lay the kinds out again when the list is resized + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched is self.scroll.viewport() and event.type() == QEvent.Type.Resize: + self._reflow() + return super().eventFilter(watched, event) + + # kind_names: the kinds listed, in order + def kind_names(self) -> list[str]: + return list(self.details) + + # add_kind: open the dialog for a new kind def add_kind(self) -> None: - taken = {self._read_row(r)[0] for r in range(self.table.rowCount())} - name = next(f'kind{i}' for i in range(1, 1000) if f'kind{i}' not in taken) - self._write_row(self.table.rowCount(), _to_cells(name, Kind(marker_a=100.0, box_a=100.0))) - - # remove_selected: drop the selected rows - def remove_selected(self) -> None: - for r in sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True): - self.table.removeRow(r) - - # collect: the kinds in the table, in order; raises ValueError naming the problem - def collect(self) -> dict[str, Kind]: - kinds: dict[str, Kind] = {} - for r in range(self.table.rowCount()): - try: - name, kind = _from_cells(self._read_row(r)) - except ValueError as e: - raise ValueError(f'row {r + 1}: {e}') from e - if name in kinds: - raise ValueError(f'row {r + 1}: duplicate kind name {name!r}') - if not QColor(kind.colour).isValid(): - raise ValueError(f'row {r + 1}: {kind.colour!r} is not a colour') - kinds[name] = kind - return kinds - - # save: validate the whole config with these kinds and write it, or show why not - def save(self) -> None: + self._edit(None) + + # edit_kind: open the dialog for an existing kind + def edit_kind(self, name: str) -> None: + self._edit(name) + + # _show: rebuild the list and the hotkey dropdowns from the config + def _show(self) -> None: + assert self.project is not None + self._loading = True + for card in self.cards: + self.grid.removeWidget(card) + card.deleteLater() + self.details, self.edit_buttons, self.cards = {}, {}, [] + for name, kind in self.project.config.kinds.items(): + self.cards.append(self._card(name, kind)) + self._columns = 0 + self._reflow() + names = [NO_KIND, *self.project.config.kinds] + for box, chosen in zip(self.hotkey_boxes, self.project.config.hotkeys, strict=True): + box.clear() + box.addItems(names) + box.setCurrentText(chosen or NO_KIND) + self._loading = False + + # _reflow: put the kinds in as many equal columns as fit, filling across then down + def _reflow(self) -> None: + columns = max(1, self.scroll.viewport().width() // _CARD_WIDTH) + if columns == self._columns: + return + for c in range(self._columns): + self.grid.setColumnStretch(c, 0) + for card in self.cards: + self.grid.removeWidget(card) + for i, card in enumerate(self.cards): + self.grid.addWidget(card, i // columns, i % columns) + for c in range(columns): + self.grid.setColumnStretch(c, 1) + self._columns = columns + + # _card: the name as a subheading with its edit button, then the kind's values as plain text + def _card(self, name: str, kind: Kind) -> QWidget: + button = QToolButton() + edit_icon = icon('edit') + if edit_icon is None: + button.setText('Edit') + else: + button.setIcon(edit_icon) + button.setAutoRaise(True) + button.setStyleSheet('QToolButton { border: none; background: transparent; }') + button.setToolTip(f'Edit {name}') + button.clicked.connect(lambda _checked=False, n=name: self.edit_kind(n)) + detail = QLabel(_describe(kind)) + detail.setTextFormat(Qt.TextFormat.RichText) + self.details[name], self.edit_buttons[name] = detail, button + row = QHBoxLayout() + row.addWidget(heading(name)) + row.addWidget(button) + row.addStretch() + card = QWidget() + box = QVBoxLayout(card) + box.setContentsMargins(0, 0, 0, 0) + box.addLayout(row) + box.addWidget(detail) + box.addStretch() + return card + + # _edit: run the dialog for a new kind (name None) or an existing one, then save, delete or do nothing + def _edit(self, name: str | None) -> None: if self.project is None: return + kinds = self.project.config.kinds + dialog = KindDialog(self, name, None if name is None else kinds[name], [k for k in kinds if k != name], lambda new, kind: self._problem(new, kind, name)) + result = dialog.exec() + if result == DELETED and name is not None: + self._delete(name) + elif result: + self._apply(*dialog.values(), old=name) + + # _proposal: the config changes for saving a kind. Renaming keeps its place, hotkey and children; a new kind takes the first free hotkey + def _proposal(self, name: str, kind: Kind, old: str | None) -> dict[str, object]: + assert self.project is not None + config = self.project.config + if old is None: + hotkeys = list(config.hotkeys) + if '' in hotkeys: + hotkeys[hotkeys.index('')] = name + return {'kinds': {**config.kinds, name: kind}, 'hotkeys': hotkeys} + kinds = {(name if k == old else k): kind if k == old else v.model_copy(update={'parent': name}) if v.parent == old else v for k, v in config.kinds.items()} + return {'kinds': kinds, 'hotkeys': [name if h == old else h for h in config.hotkeys]} + + # _problem: why a proposed kind cannot be saved, or None + def _problem(self, name: str, kind: Kind, old: str | None) -> str | None: + assert self.project is not None + if name != old and name in self.project.config.kinds: + return f'A kind called {name!r} already exists.' try: - self.project.update(kinds=self.collect()) + self.project.config.replace(**self._proposal(name, kind, old)) except ValidationError as e: - self.status.setText(f'Not saved: {e.errors()[0]["msg"]}') + return e.errors()[0]['msg'] + return None + + # _apply: save a new or edited kind; a rename takes the kind's picks and fits with it + def _apply(self, name: str, kind: Kind, *, old: str | None = None) -> None: + assert self.project is not None + self.project.update(**self._proposal(name, kind, old)) + if old is not None and old != name: + if self.service is not None: + self.service.store.rename_kind(old, name) + if self.session is not None: + self.session.store.rename_kind(old, name) + self._saved() + + # _delete: remove a kind and its picks, asking first if it has any; refused with a reason if the config would not be valid + def _delete(self, name: str) -> None: + assert self.project is not None + config = self.project.config + changes = {'kinds': {k: v for k, v in config.kinds.items() if k != name}, 'hotkeys': ['' if h == name else h for h in config.hotkeys]} + try: + config.replace(**changes) + except ValidationError as e: + self.status.setText(f'Not deleted: {e.errors()[0]["msg"]}') return - except ValueError as e: - self.status.setText(f'Not saved: {e}') + picks = self.session.store.all(name) if self.session is not None else [] + if picks and not self._confirm(f'Delete kind {name!r} and its {len(picks)} picks in {len({p.tomogram for p in picks})} tomograms? This cannot be undone.'): return + self.project.update(**changes) + if self.session is not None: + self.session.store.remove_kind(name) + self._saved() + + # _confirm: ask a yes or no question + def _confirm(self, text: str) -> bool: + return QMessageBox.question(self, 'Delete kind', text) == QMessageBox.StandardButton.Yes + + # _hotkey_changed: save a hotkey choice; a kind lives on one key, so choosing it moves it + def _hotkey_changed(self, slot: int, text: str) -> None: + if self._loading or self.project is None: + return + self._loading = True + for i, box in enumerate(self.hotkey_boxes): + if i != slot and text != NO_KIND and box.currentText() == text: + box.setCurrentText(NO_KIND) + self._loading = False + keys = [b.currentText() for b in self.hotkey_boxes] + self.project.update(hotkeys=['' if k == NO_KIND else k for k in keys]) + self._saved() + + # _saved: show the config as written and tell the other tabs + def _saved(self) -> None: + self._show() self.status.setText('Saved') self.config_saved.emit() - # _read_row: the cell strings of a row - def _read_row(self, r: int) -> list[str]: - out = [] - for c in range(len(_COLUMNS)): - if c == _ESTIMATOR_COL: - out.append(self.table.cellWidget(r, c).currentText()) - else: - item = self.table.item(r, c) - out.append(item.text().strip() if item else '') - return out - - # _write_row: set the cell strings of a row, creating it if needed - def _write_row(self, r: int, cells: list[str]) -> None: - if r >= self.table.rowCount(): - self.table.insertRow(r) - for c, text in enumerate(cells): - if c == _ESTIMATOR_COL: - # keep a name the registry does not know yet, so loading never rewrites it - combo = QComboBox() - combo.addItems(sorted({*ESTIMATORS, text})) - combo.setCurrentText(text) - self.table.setCellWidget(r, c, combo) - else: - item = QTableWidgetItem(text) - self.table.setItem(r, c, item) - if c == _COLOUR_COL: - self._paint(item) - - # _move: swap the selected row with its neighbour - def _move(self, delta: int) -> None: - rows = {i.row() for i in self.table.selectedIndexes()} - if len(rows) != 1: - return - a = rows.pop() - b = a + delta - if not 0 <= b < self.table.rowCount(): - return - cells_a, cells_b = self._read_row(a), self._read_row(b) - self._write_row(a, cells_b) - self._write_row(b, cells_a) - self.table.selectRow(b) - - # _paint: shade a colour cell with its colour - def _paint(self, item: QTableWidgetItem) -> None: - if item.column() == _COLOUR_COL: - colour = QColor(item.text().strip()) - item.setBackground(colour if colour.isValid() else QColor('white')) - -# _to_cells: the table strings of a kind -def _to_cells(name: str, kind: Kind) -> list[str]: - tilt = '' if kind.tilt_prior_deg is None else f'{kind.tilt_prior_deg:g}' - return [ - name, f'{kind.marker_a:g}', f'{kind.box_a:g}', kind.estimator, kind.colour, kind.parent or '', - f'{kind.parent_tolerance_a:g}', tilt, f'{kind.extract_angpix:g}', +# _describe: a kind's values as a vertical list with bold keys +def _describe(kind: Kind) -> str: + box = 'from estimator' if kind.box_a == 0 else f'{kind.box_a:g} Å' + parent = 'none' if kind.parent is None else f'{kind.parent} (within {kind.parent_tolerance_a:g} Å)' + tilt = 'none' if kind.tilt_prior_deg is None else f'{kind.tilt_prior_deg:g}°' + pairs = [ + ('Marker', f'{kind.marker_a:g} Å'), ('Box', box), ('Estimator', kind.estimator), ('Colour', kind.colour), + ('Parent', parent), ('Tilt prior', tilt), ('Extract', f'{kind.extract_angpix:g} Å/px'), ] - -# _from_cells: the name and Kind for table strings, ValueError on anything unreadable -def _from_cells(cells: list[str]) -> tuple[str, Kind]: - name, marker, box, estimator, colour, parent, tolerance, tilt, angpix = cells - try: - kind = Kind( - marker_a=float(marker), box_a=float(box), estimator=estimator, colour=colour, parent=parent or None, - parent_tolerance_a=float(tolerance), tilt_prior_deg=float(tilt) if tilt else None, extract_angpix=float(angpix), - ) - except ValidationError as e: - err = e.errors()[0] - raise ValueError(f'{name or "(unnamed)"}: {err["loc"][0]} {err["msg"]}') from e - return name, kind + return '
'.join(f'{key}: {html.escape(value)}' for key, value in pairs) diff --git a/src/pickmix/gui/main_window.py b/src/pickmix/gui/main_window.py index 2d7a38a..6bdfdac 100644 --- a/src/pickmix/gui/main_window.py +++ b/src/pickmix/gui/main_window.py @@ -43,12 +43,16 @@ def __init__(self) -> None: for i in range(1, self.tabs.count()): self.tabs.setTabEnabled(i, False) self.setCentralWidget(self.tabs) - dock = QDockWidget('Tomograms') - dock.setWidget(self.dock) - dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) - self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock) + self.dock_widget = QDockWidget('Tomograms') + self.dock_widget.setWidget(self.dock) + self.dock_widget.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) + self.dock_widget.hide() + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.dock_widget) + self.tabs.currentChanged.connect(self._sync_dock) + self.project_tab.project_changed.connect(self._on_project) self.project_tab.project_changed.connect(self._on_project) self.project_tab.config_saved.connect(self._on_settings) + self.project_tab.project_closed.connect(self._on_closed) self.kinds_tab.config_saved.connect(self._on_kinds) self.review_tab.open_pick.connect(self._open_pick) for keys, delta in (('Return', 1), ('Enter', 1), ('Shift+Return', -1), ('Shift+Enter', -1)): @@ -67,14 +71,15 @@ def _step(self, delta: int) -> None: return self.session.step(delta) + # _sync_dock: the tomogram dock is only for the Pick tab, and only with a session + def _sync_dock(self) -> None: + self.dock_widget.setVisible(self.session is not None and self.tabs.currentWidget() is self.pick_tab) + # _on_project: build the session and loader for a project and unlock the tabs def _on_project(self, project: Project) -> None: self.project = project self.setWindowTitle(f'pickmix: {project.path}') - if self.service is not None: - self.service.shutdown() - if self.loader is not None: - self.loader.shutdown() + self._shutdown() self.kinds_tab.set_project(project) if project.tomos: self.session, self.loader = Session(project), Loader(project) @@ -82,14 +87,34 @@ def _on_project(self, project: Project) -> None: self.pick_tab.set_session(self.session, self.loader, self.service) self.review_tab.set_session(self.session, self.loader, self.service) self.dock.set_session(self.session) - self.kinds_tab.average.set_session(self.session, self.service) + self.kinds_tab.set_session(self.session, self.service) else: self.session = self.loader = self.service = None - self.kinds_tab.average.set_session(None, None) + self.kinds_tab.set_session(None, None) for i in range(1, self.tabs.count()): self.tabs.setTabEnabled(i, True) self.tabs.setTabEnabled(2, self.session is not None) self.tabs.setTabEnabled(3, self.session is not None) + self._sync_dock() + + # _shutdown: stop the estimate service and loader of the current project, if any + def _shutdown(self) -> None: + if self.service is not None: + self.service.shutdown() + if self.loader is not None: + self.loader.shutdown() + self.session = self.loader = self.service = None + + # _on_closed: no project; lock everything but the Project tab + def _on_closed(self) -> None: + self._shutdown() + self.project = None + self.setWindowTitle('pickmix') + self.kinds_tab.set_session(None, None) + for i in range(1, self.tabs.count()): + self.tabs.setTabEnabled(i, False) + self.tabs.setCurrentWidget(self.project_tab) + self._sync_dock() # _on_settings: source or estimator settings changed, so start the session and loader afresh def _on_settings(self) -> None: diff --git a/src/pickmix/gui/new_project_dialog.py b/src/pickmix/gui/new_project_dialog.py index 426c00e..dde7f6d 100644 --- a/src/pickmix/gui/new_project_dialog.py +++ b/src/pickmix/gui/new_project_dialog.py @@ -9,6 +9,9 @@ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) +# Import internal pickmix objects +from pickmix.gui.style import tidy + # PathField: a line edit with a browse button for a directory class PathField(QWidget): # __init__: build the field @@ -66,6 +69,7 @@ def __init__(self, parent: QWidget | None = None) -> None: row.addWidget(remove) row.addStretch() layout = QVBoxLayout(self) + tidy(layout) layout.addLayout(form) layout.addWidget(self.runs) layout.addLayout(row) diff --git a/src/pickmix/gui/pick_tab.py b/src/pickmix/gui/pick_tab.py index 9604c4f..664b45f 100644 --- a/src/pickmix/gui/pick_tab.py +++ b/src/pickmix/gui/pick_tab.py @@ -8,15 +8,16 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( - QButtonGroup, QCheckBox, QHBoxLayout, QLabel, QPushButton, - QSlider, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QButtonGroup, QCheckBox, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QScrollBar, QSlider, QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, ) # Import internal pickmix objects from pickmix.estimates import EstimateService -from pickmix.gui.canvas import FitView, Marker, SliceCanvas from pickmix.geometry import Parent, match_parents +from pickmix.gui.canvas import FitView, Marker, SliceCanvas from pickmix.gui.workers import post +from pickmix.gui.style import bold_safe, eye_toggle, heading, hint, tidy, title from pickmix.loader import Loader, Opened from pickmix.model import Session from pickmix.timing import Timer @@ -42,13 +43,15 @@ def __init__(self) -> None: self._first_slice = False self._range = (0.0, 1.0) self.canvas = SliceCanvas() - self.title = QLabel() + self.title = title() + self.help = hint('Enter: next tomogram. Shift+Enter: previous tomogram.') self.status = QLabel() self.kind_row = QHBoxLayout() self.kind_buttons: dict[str, QPushButton] = {} - self.kind_boxes: dict[str, QCheckBox] = {} + self.kind_toggles: dict[str, QPushButton] = {} self.group = QButtonGroup(self) - self.z_slider = QSlider(Qt.Orientation.Horizontal) + self.z_slider = QScrollBar(Qt.Orientation.Vertical) + self.z_slider.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.z_label = QLabel() self.lo_slider = QSlider(Qt.Orientation.Horizontal) self.hi_slider = QSlider(Qt.Orientation.Horizontal) @@ -67,37 +70,60 @@ def __init__(self) -> None: self.table.setHorizontalHeaderLabels(['Kind', 'x', 'y', 'z', 'SNR', 'Flag']) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.table.setMaximumWidth(380) + self.table.setMaximumHeight(170) + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.delete_button = QPushButton('Delete selected') for button in (self.auto_button, self.fit_button, self.delete_button): button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._row_ids: list[int] = [] - z_row = QHBoxLayout() - z_row.addWidget(QLabel('Slice')) - z_row.addWidget(self.z_slider, 1) - z_row.addWidget(self.z_label) - contrast_row = QHBoxLayout() - contrast_row.addWidget(QLabel('Contrast')) - contrast_row.addWidget(self.lo_slider, 1) - contrast_row.addWidget(self.hi_slider, 1) - contrast_row.addWidget(self.rings_box) - contrast_row.addWidget(self.boxes_box) - contrast_row.addWidget(self.normals_box) - contrast_row.addWidget(self.invert_box) - contrast_row.addWidget(self.auto_button) - contrast_row.addWidget(self.fit_button) - side = QVBoxLayout() - side.addWidget(self.table, 1) - side.addWidget(self.delete_button) + # settings pane on the right: image controls, then overlays + image = QGroupBox('Image') + image_form = QFormLayout(image) + image_form.addRow('Contrast low', self.lo_slider) + image_form.addRow('Contrast high', self.hi_slider) + image_form.addRow(self.invert_box) + buttons = QHBoxLayout() + buttons.addWidget(self.auto_button) + buttons.addWidget(self.fit_button) + image_form.addRow(buttons) + overlay = QGroupBox('Overlay') + overlay_box = QVBoxLayout(overlay) + for box in (self.rings_box, self.boxes_box, self.normals_box): + overlay_box.addWidget(box) + self.settings = QWidget() + self.settings.setFixedWidth(260) + settings_layout = QVBoxLayout(self.settings) + settings_layout.setContentsMargins(0, 0, 0, 0) + settings_layout.addWidget(image) + settings_layout.addWidget(overlay) + settings_layout.addStretch() + # vertical Z scrollbar between the canvas and the settings + z_column = QVBoxLayout() + z_column.addWidget(QLabel('Z')) + z_column.addWidget(self.z_slider, 1) + z_column.addWidget(self.z_label) middle = QHBoxLayout() middle.addWidget(self.canvas, 1) - middle.addLayout(side) + middle.addLayout(z_column) + middle.addWidget(self.settings) + # pick table under the canvas, with a Picks heading and the delete button level with it on the right + picks_head = QHBoxLayout() + picks_head.addWidget(heading('Picks')) + picks_head.addStretch() + picks_head.addWidget(self.delete_button) + bottom = QVBoxLayout() + bottom.addLayout(picks_head) + bottom.addWidget(self.table) layout = QVBoxLayout(self) + tidy(layout) layout.addWidget(self.title) + layout.addWidget(self.help) + layout.addSpacing(8) layout.addLayout(self.kind_row) layout.addLayout(middle, 1) - layout.addLayout(z_row) - layout.addLayout(contrast_row) + layout.addLayout(bottom) layout.addWidget(self.status) self.z_slider.valueChanged.connect(self._show_z) self.lo_slider.valueChanged.connect(self._on_window) @@ -131,7 +157,7 @@ def set_session(self, session: Session, loader: Loader, service: EstimateService self.refresh_kinds() self._on_goto() - # refresh_kinds: rebuild the kind buttons from the config, keeping the active kind if it still exists + # refresh_kinds: rebuild the hotkeys row from the config, keeping the active kind if it still exists def refresh_kinds(self) -> None: if self.session is None: return @@ -141,25 +167,50 @@ def refresh_kinds(self) -> None: widget = self.kind_row.takeAt(0).widget() if widget is not None: widget.deleteLater() - self.kind_buttons, self.kind_boxes = {}, {} + self.kind_buttons, self.kind_toggles = {}, {} kinds = self.session.project.config.kinds self.hidden &= set(kinds) - for i, (name, kind) in enumerate(kinds.items()): - button = QPushButton(f'{i + 1} {name}' if i < 9 else name) - button.setCheckable(True) - button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - button.setStyleSheet(f'QPushButton {{ border-bottom: 4px solid {kind.colour}; padding: 4px 10px; }}') - button.clicked.connect(lambda _checked=False, n=name: self._set_active(n)) - box = QCheckBox('show') - box.setChecked(name not in self.hidden) - box.setFocusPolicy(Qt.FocusPolicy.NoFocus) - box.toggled.connect(lambda shown, n=name: self._toggle_kind(n, shown)) - self.group.addButton(button) - self.kind_buttons[name], self.kind_boxes[name] = button, box - self.kind_row.addWidget(button) - self.kind_row.addWidget(box) + # key number of each kind that has a hotkey + keys = {name: i + 1 for i, name in enumerate(self.session.project.config.hotkeys) if name} + self.kind_row.addWidget(hint('Hotkeys:')) + for name, kind in kinds.items(): + # first button selects kind + key = QPushButton(f'({keys[name]}) {name}' if name in keys else name) + key.setCheckable(True) + key.setFlat(True) + key.setFocusPolicy(Qt.FocusPolicy.NoFocus) + key.setCursor(Qt.CursorShape.PointingHandCursor) + bold_safe(key) + key.clicked.connect(lambda _checked=False, n=name: self._set_active(n)) + # second button shows/hides markers + toggle = QToolButton() + toggle.setCheckable(True) + toggle.setChecked(name not in self.hidden) + toggle.setAutoRaise(True) + toggle.setStyleSheet('QToolButton { border: none; background: transparent; }') + toggle.setCursor(Qt.CursorShape.PointingHandCursor) + toggle.setFocusPolicy(Qt.FocusPolicy.NoFocus) + toggle.setToolTip(f'Show or hide {name}') + eye_toggle(toggle, toggle.isChecked()) + toggle.toggled.connect(lambda shown, n=name, t=toggle: (eye_toggle(t, shown), self._toggle_kind(n, shown))) + self.group.addButton(key) + self.kind_buttons[name], self.kind_toggles[name] = key, toggle + # add rounded border in kind's colour around both buttons + pair = QFrame() + pair.setObjectName('pair') + pair.setStyleSheet(f'QFrame#pair {{ border: 2px solid {kind.colour}; border-radius: 8px; }}') + pair_row = QHBoxLayout(pair) + pair_row.setContentsMargins(4, 2, 4, 2) + pair_row.setSpacing(0) + pair_row.addWidget(key) + pair_row.addWidget(toggle) + self.kind_row.addWidget(pair) self.kind_row.addStretch() - self._set_active(self.active if self.active in kinds else next(iter(kinds))) + # no kinds leaves nothing to pick as + if kinds: + self._set_active(self.active if self.active in kinds else next(iter(kinds))) + else: + self.active = None self._on_picks() # show_pick: go to a pick, in whichever tomogram it is, and select it @@ -183,11 +234,11 @@ def _set_active(self, name: str) -> None: self.active = name self.kind_buttons[name].setChecked(True) - # _pick_kind: choose the i-th kind by number key + # _pick_kind: choose the kind on the i-th hotkey def _pick_kind(self, i: int) -> None: - names = list(self.kind_buttons) - if i < len(names): - self._set_active(names[i]) + name = self.session.project.config.hotkeys[i] if self.session is not None else '' + if name in self.kind_buttons: + self._set_active(name) # _toggle_kind: show or hide a kind's markers def _toggle_kind(self, name: str, shown: bool) -> None: @@ -200,7 +251,7 @@ def _on_goto(self) -> None: name = self.session.current self.opened = None self.canvas.clear() - self.title.setText(f'{name} ({self.session.index + 1}/{len(self.session.names)}) Enter next, Shift+Enter back') + self.title.setText(name) self.status.setText(f'Loading {name}...') self._fill_table() self._timer = Timer(f'switch to {name}') @@ -249,7 +300,11 @@ def _show_z(self, z: int) -> None: return name = self.opened.name self.z = z - self.z_label.setText(f'{z + 1} / {self.opened.vol.shape[0]}') + total = self.opened.vol.shape[0] + # as wide as the longest text it will show, so scrolling never changes the layout + self.z_label.setFixedWidth(self.z_label.fontMetrics().horizontalAdvance(f'{total} / {total}') + 8) + self.z_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.z_label.setText(f'{z + 1} / {total}') self.canvas.set_z(z) cached = self.loader.cached(name, z) if cached is not None: diff --git a/src/pickmix/gui/project_tab.py b/src/pickmix/gui/project_tab.py index cceb4d1..2ea35b5 100644 --- a/src/pickmix/gui/project_tab.py +++ b/src/pickmix/gui/project_tab.py @@ -5,16 +5,18 @@ # Import external dependencies from pathlib import Path from pydantic import BaseModel, ValidationError -from PySide6.QtCore import Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( - QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QPushButton, - QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QComboBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, + QPushButton, QSpinBox, QStackedWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) # Import internal pickmix objects from pickmix.config import RingParams +from pickmix.gui.collapsible import Collapsible from pickmix.gui.new_project_dialog import NewProjectDialog +from pickmix.gui.style import form_label, heading, tidy from pickmix.gui.workers import run_in_background from pickmix.project import Project, create_project, open_project from pickmix.utils.star import MergeResult @@ -22,22 +24,30 @@ # _COLUMNS: tomogram table headings _COLUMNS: list[str] = ['Name', 'Shape (zyx)', 'Pixel size (A)', 'Denoised', 'Status'] -# _ParamsForm: a form for a flat pydantic model of numbers and number pairs, built from its current values +# _ParamsForm: a form for a flat pydantic model of numbers and number pairs, labelled from the field titles class _ParamsForm(QWidget): - # __init__: one row per field + # __init__: one row per field, "Title (units) (?): [a] - [b]" def __init__(self, params: BaseModel) -> None: super().__init__() self._cls = type(params) self._boxes: dict[str, list[QSpinBox | QDoubleSpinBox]] = {} + self.labels: dict[str, QLabel] = {} layout = QFormLayout(self) for name, value in params: + info = type(params).model_fields[name] values = value if isinstance(value, tuple) else (value,) boxes = [_spin(v) for v in values] self._boxes[name] = boxes row = QHBoxLayout() - for box in boxes: + for i, box in enumerate(boxes): + if i: + row.addWidget(QLabel('-')) row.addWidget(box) - layout.addRow(name, row) + row.addStretch() + # "Title (units) (?):" in the label column, then the values + head = form_label(info.title or name, info.description or '') + self.labels[name] = head.findChild(QLabel) + layout.addRow(head, row) # set: show the values of a model def set(self, params: BaseModel) -> None: @@ -55,16 +65,18 @@ class ProjectTab(QWidget): project_changed = Signal(object) # config_saved: settings of the open project were written to config.toml config_saved = Signal() + # project_closed: the open project was closed + project_closed = Signal() # __init__: build the layout def __init__(self) -> None: super().__init__() self.project: Project | None = None - self.path_label = QLabel('No project open') - self.open_button = QPushButton('Open config...') + self.path_label = QLabel() + self.open_button = QPushButton('Open project...') self.new_button = QPushButton('New project...') self.reload_button = QPushButton('Reload config') - self.reload_button.setEnabled(False) + self.close_button = QPushButton('Close project') self.status = QLabel() self.summary = QLabel() self.table = QTableWidget(0, len(_COLUMNS)) @@ -75,33 +87,57 @@ def __init__(self) -> None: self.source_box.addItems(['denoised', 'raw']) self.ring_form = _ParamsForm(RingParams()) self.save_button = QPushButton('Save settings') - self.settings = QGroupBox('Settings') - form = QVBoxLayout(self.settings) - source = QFormLayout() + general = QGroupBox('General') + source = QFormLayout(general) source.addRow('Volume source', self.source_box) - form.addLayout(source) - form.addWidget(QLabel('Ring estimator')) - form.addWidget(self.ring_form) + estimators = QGroupBox('Estimators') + estimator_box = QVBoxLayout(estimators) + self.ring_section = Collapsible('Ring', self.ring_form) + estimator_box.addWidget(self.ring_section) + self.settings = QWidget() + self.settings.setMinimumWidth(self.ring_form.sizeHint().width() + 48) + form = QVBoxLayout(self.settings) + form.setContentsMargins(0, 0, 0, 0) + form.addWidget(heading('Settings')) + form.addWidget(general) + form.addWidget(estimators) form.addWidget(self.save_button) - self.settings.setEnabled(False) + form.addStretch() + start = QWidget() + start_layout = QVBoxLayout(start) + start_layout.addStretch() + start_layout.addWidget(heading('No project open'), alignment=Qt.AlignmentFlag.AlignHCenter) + for button in (self.new_button, self.open_button): + button.setMinimumWidth(220) + start_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignHCenter) + start_layout.addStretch() + # page 1: the open project top = QHBoxLayout() - top.addWidget(self.open_button) - top.addWidget(self.new_button) - top.addWidget(self.reload_button) top.addWidget(self.path_label, 1) + top.addWidget(self.reload_button) + top.addWidget(self.close_button) left = QVBoxLayout() left.addWidget(self.summary) left.addWidget(self.table) body = QHBoxLayout() body.addLayout(left, 3) body.addWidget(self.settings, 1) + opened = QWidget() + opened_layout = QVBoxLayout(opened) + opened_layout.setContentsMargins(0, 0, 0, 0) + opened_layout.addLayout(top) + opened_layout.addLayout(body) + self.stack = QStackedWidget() + self.stack.addWidget(start) + self.stack.addWidget(opened) layout = QVBoxLayout(self) - layout.addLayout(top) + tidy(layout) + layout.addWidget(self.stack, 1) layout.addWidget(self.status) - layout.addLayout(body) self.open_button.clicked.connect(self._open_dialog) self.new_button.clicked.connect(self._new_dialog) self.reload_button.clicked.connect(self._reload) + self.close_button.clicked.connect(self.close_project) self.save_button.clicked.connect(self.save_settings) # open_config: open a config.toml in the background @@ -116,10 +152,17 @@ def set_project(self, project: Project) -> None: self._fill_table(project) self.source_box.setCurrentText(project.config.source) self.ring_form.set(project.config.ring) - self.settings.setEnabled(True) + self.stack.setCurrentIndex(1) self._idle('') self.project_changed.emit(project) + # close_project: close the open project and go back to the start page + def close_project(self) -> None: + self.project = None + self.stack.setCurrentIndex(0) + self.status.setText('') + self.project_closed.emit() + # save_settings: write source and ring parameters to config.toml def save_settings(self) -> None: if self.project is None: @@ -182,18 +225,16 @@ def _created(self, made: tuple[Project, MergeResult]) -> None: self.set_project(project) self.status.setText(f'Created with {result.count} tomograms, {len(result.skipped)} duplicates skipped') - # _busy: block the open, create and reload buttons while work runs + # _busy: block the open, create, reload and close buttons while work runs def _busy(self, message: str) -> None: - self.open_button.setEnabled(False) - self.new_button.setEnabled(False) - self.reload_button.setEnabled(False) + for button in (self.open_button, self.new_button, self.reload_button, self.close_button): + button.setEnabled(False) self.status.setText(message) - # _idle: unblock the buttons, reload only with a project open + # _idle: unblock the buttons def _idle(self, message: str) -> None: - self.open_button.setEnabled(True) - self.new_button.setEnabled(True) - self.reload_button.setEnabled(self.project is not None) + for button in (self.open_button, self.new_button, self.reload_button, self.close_button): + button.setEnabled(True) self.status.setText(message) # _failed: show an error from background work diff --git a/src/pickmix/gui/review_tab.py b/src/pickmix/gui/review_tab.py index d607c64..43ef43d 100644 --- a/src/pickmix/gui/review_tab.py +++ b/src/pickmix/gui/review_tab.py @@ -18,6 +18,7 @@ from pickmix.extract import extract_kind from pickmix.geometry import Parent, match_parents from pickmix.gui.images import to_pixmap +from pickmix.gui.style import tidy from pickmix.gui.workers import post, run_in_background from pickmix.loader import Loader from pickmix.model import Pick, Session @@ -111,6 +112,7 @@ def __init__(self) -> None: box.addLayout(buttons) box.addWidget(self.log) layout = QVBoxLayout(self) + tidy(layout) layout.addLayout(filters) layout.addLayout(body, 1) layout.addWidget(export) diff --git a/src/pickmix/gui/style.py b/src/pickmix/gui/style.py new file mode 100644 index 0000000..00ff1e7 --- /dev/null +++ b/src/pickmix/gui/style.py @@ -0,0 +1,108 @@ +''' +pickmix: shared GUI text levels and spacing +''' + +# Import external dependencies +from pathlib import Path +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QLayout, QWidget + +# MARGIN, SPACING: outer margin and gap between widgets, pixels +MARGIN: int = 8 +SPACING: int = 6 + +# TITLE_STEP, HEADING_STEP, HINT_STEP: point-size offsets from the application font for each text level +TITLE_STEP: float = 4.0 +HEADING_STEP: float = 1.0 +HINT_STEP: float = -1.0 + +# ICON_DIR: bundled icon files +ICON_DIR: Path = Path(__file__).parent / 'icons' + +# _SMALL_NATIVE: widget classes whose native font is smaller than a label's +_SMALL_NATIVE: tuple[str, ...] = ('QGroupBox', 'QToolButton', 'QHeaderView') + +# body_pt: point size of an ordinary label, the reference every other widget is matched to +def body_pt() -> float: + return QLabel().font().pointSizeF() + +# standardise: give widgets with a smaller native font the body size, so all text is one size unless a level says otherwise +def standardise(app: QApplication) -> None: + app.setStyleSheet(f'{", ".join(_SMALL_NATIVE)} {{ font-size: {body_pt()}pt; }}') + +# icon: a bundled icon by file stem, or None when its file is not there so the caller can fall back to text +def icon(stem: str) -> QIcon | None: + path = ICON_DIR / f'{stem}.svg' + return QIcon(str(path)) if path.exists() else None + +# help_marker: the question mark icon, or (?) without its file, explaining itself on hover +def help_marker(text: str) -> QLabel: + label = QLabel() + help_icon = icon('help') + if help_icon is None: + label.setText('(?)') + else: + label.setPixmap(help_icon.pixmap(16, 16)) + label.setToolTip(text) + label.setCursor(Qt.CursorShape.WhatsThisCursor) + return label + +# eye_toggle: small grey eye icon (open when shown, crossed when hidden) on a toggle, or the words (hidden)/(shown) without the icon files +def eye_toggle(button: QPushButton, shown: bool) -> None: + eye = icon('visibility' if shown else 'visibility_off') + if eye is None: + button.setText('(hidden)' if shown else '(shown)') + else: + button.setIcon(eye) + +# form_label: "Title (?):" for the label column of a form, the marker explaining the field on hover +def form_label(title: str, tip: str) -> QWidget: + label = QLabel(title) + label.setToolTip(tip) + box = QWidget() + row = QHBoxLayout(box) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(4) + row.addWidget(label) + row.addWidget(help_marker(tip)) + row.addWidget(QLabel(':')) + row.addStretch() + return box + +# tidy: apply the shared margin and spacing to a layout +def tidy(layout: QLayout) -> None: + layout.setContentsMargins(MARGIN, MARGIN, MARGIN, MARGIN) + layout.setSpacing(SPACING) + +# title: large bold label, for the name of the thing being worked on +def title(text: str = '') -> QLabel: + return _styled(text, TITLE_STEP, bold=True) + +# heading: slightly larger bold label, for a section +def heading(text: str = '') -> QLabel: + return _styled(text, HEADING_STEP, bold=True) + +# hint: small grey label, for help text +def hint(text: str = '') -> QLabel: + label = _styled(text, HINT_STEP, bold=False) + label.setStyleSheet('color: gray') + return label + +# bold_safe: style a checkable text button so it is bold when checked without changing its width +def bold_safe(button: QPushButton) -> None: + plain = 'QPushButton { border: none; background: transparent; padding: 2px 4px; }' + bold = 'QPushButton { border: none; background: transparent; padding: 2px 4px; font-weight: bold; }' + button.setStyleSheet(bold) + button.ensurePolished() + button.setMinimumWidth(button.sizeHint().width()) + button.setStyleSheet(plain + 'QPushButton:checked { font-weight: bold; }') + +# _styled: a label in the application font shifted by step points +def _styled(text: str, step: float, *, bold: bool) -> QLabel: + label = QLabel(text) + font = label.font() + font.setPointSizeF(font.pointSizeF() + step) + font.setBold(bold) + label.setFont(font) + return label diff --git a/src/pickmix/gui/tomo_dock.py b/src/pickmix/gui/tomo_dock.py index babd2c3..21ae10f 100644 --- a/src/pickmix/gui/tomo_dock.py +++ b/src/pickmix/gui/tomo_dock.py @@ -3,28 +3,35 @@ ''' # Import external dependencies -from PySide6.QtGui import QFontDatabase +from PySide6.QtGui import QColor, QFontDatabase from PySide6.QtWidgets import QLabel, QLineEdit, QListWidget, QListWidgetItem, QVBoxLayout, QWidget # Import internal pickmix objects from pickmix.model import Session -# TomoDock: one line per tomogram, current marked > and opened marked * +# COLOUR_VISITED, COLOUR_PENDING, COLOUR_CURRENT: line colours for opened, not yet opened and current tomograms +COLOUR_VISITED: QColor = QColor('#2e9e44') +COLOUR_PENDING: QColor = QColor('#e08a00') +COLOUR_CURRENT: QColor = QColor('#2f7fe0') + +# TomoDock: one line per tomogram with number, name and picks, coloured by state class TomoDock(QWidget): # __init__: build the widgets def __init__(self) -> None: super().__init__() self.session: Session | None = None + self.visited = QLabel() self.jump_edit = QLineEdit() self.jump_edit.setPlaceholderText('go to number or name, then Enter') + self.header = QLabel() self.list = QListWidget() self.list.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) - self.totals = QLabel() - self.totals.setFont(self.list.font()) + self.header.setFont(self.list.font()) layout = QVBoxLayout(self) + layout.addWidget(self.visited) layout.addWidget(self.jump_edit) + layout.addWidget(self.header) layout.addWidget(self.list, 1) - layout.addWidget(self.totals) self.jump_edit.returnPressed.connect(self._jump) self.list.itemClicked.connect(self._clicked) @@ -36,7 +43,7 @@ def set_session(self, session: Session) -> None: self.list.clear() self.refresh() - # refresh: update the lines and totals in place + # refresh: update the lines in place def refresh(self) -> None: if self.session is None: return @@ -45,14 +52,18 @@ def refresh(self) -> None: self.list.clear() for _ in rows: self.list.addItem(QListWidgetItem()) + width = max((len(r.name) for r in rows), default=0) + self.header.setText(f'{"No.":>3} {"Tomogram":<{width}} Picks') for row in rows: - self.list.item(row.index).setText(f'{">" if row.current else " "} {"*" if row.visited else " "} {row.index + 1:>3} {row.name} {row.count}') + item = self.list.item(row.index) + item.setText(f'{row.index + 1:>3} {row.name:<{width}} {row.count:>5}') + item.setForeground(COLOUR_CURRENT if row.current else COLOUR_VISITED if row.visited else COLOUR_PENDING) + font = item.font() + font.setBold(row.current) + item.setFont(font) if rows: self.list.scrollToItem(self.list.item(self.session.index)) - opened = sum(r.visited for r in rows) - lines = [f'visited {opened}/{len(rows)}'] - lines += [f'{name:<10}{self.session.store.total(name)}' for name in self.session.project.config.kinds] - self.totals.setText('\n'.join(lines)) + self.visited.setText(f'Visited {sum(r.visited for r in rows)}/{len(rows)}') # _jump: go to the typed number or name def _jump(self) -> None: diff --git a/src/pickmix/model.py b/src/pickmix/model.py index 2e51642..09b5370 100644 --- a/src/pickmix/model.py +++ b/src/pickmix/model.py @@ -101,6 +101,22 @@ def remove(self, *pick_ids: int) -> None: del self._picks[pick_id] self._changed() + # rename_kind: move every pick of a kind to a new name; saves and notifies once, returns how many moved + def rename_kind(self, old: str, new: str) -> int: + moved = self.all(old) + for pick in moved: + pick.kind = new + if moved: + self._changed() + return len(moved) + + # remove_kind: delete every pick of a kind; saves and notifies once, returns how many went + def remove_kind(self, kind: str) -> int: + ids = [p.id for p in self.all(kind)] + if ids: + self.remove(*ids) + return len(ids) + # save: write the CSV atomically def save(self) -> None: pixel = {name: t.pixel_size for name, t in self.project.tomos.items()} diff --git a/tests/gui/test_average_pane.py b/tests/gui/test_average_pane.py index f71c7cb..c6c9e4f 100644 --- a/tests/gui/test_average_pane.py +++ b/tests/gui/test_average_pane.py @@ -35,13 +35,14 @@ def pane(qtbot: QtBot, make_config: Callable[..., Path]) -> Iterator[tuple[Avera # TestAveragePane: Average pane class TestAveragePane: - # test_lists_tomograms_with_picks: only tomograms with picks of the kind are offered, all selected - def test_lists_tomograms_with_picks(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: + # test_summary: picks, tomograms and picks per tomogram for the chosen kind + def test_summary(self, pane: tuple[AveragePane, Session, EstimateService]) -> None: widget, session, _ = pane - assert widget.tomo_list.count() == 0 + assert widget.summary.text() == 'No picks of this kind' session.store.add(NAME, 'yellow', 4.0, 3.0, 2.0) + session.store.add(NAME, 'yellow', 5.0, 3.0, 2.0) widget.refresh_kinds() - assert widget.tomo_list.count() == 1 and len(widget.tomo_list.selectedItems()) == 1 + assert widget.summary.text() == '2 picks in 1 tomograms (2.0 per tomogram)' # test_run_shows_images: running averages the picks and shows the mean and montage def test_run_shows_images(self, qtbot: QtBot, pane: tuple[AveragePane, Session, EstimateService]) -> None: diff --git a/tests/gui/test_kind_dialog.py b/tests/gui/test_kind_dialog.py new file mode 100644 index 0000000..9b4aa50 --- /dev/null +++ b/tests/gui/test_kind_dialog.py @@ -0,0 +1,100 @@ +''' +pickmix: tests for the kind dialog +''' + +# Import external dependencies +from pytestqt.qtbot import QtBot + +# Import internal pickmix objects +from pickmix.config import Kind +from pickmix.gui.kind_dialog import DELETED, KindDialog + +# _ok: a check that accepts anything +def _ok(_name: str, _kind: Kind) -> str | None: + return None + +# TestKindDialog: kind dialog +class TestKindDialog: + # test_values_round_trip: showing a kind and reading it back gives the same kind, including parent and tilt prior + def test_values_round_trip(self, qtbot: QtBot) -> None: + kind = Kind(marker_a=40, box_a=200, estimator='ring', colour='#00ff00', parent='cage', parent_tolerance_a=80, tilt_prior_deg=0, extract_angpix=7.5) + dialog = KindDialog(None, 'green', kind, ['cage'], _ok) + qtbot.addWidget(dialog) + assert dialog.values() == ('green', kind) + + # test_defaults_when_adding: a new kind starts as an editable passthrough with no tilt prior + def test_defaults_when_adding(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, None, None, [], _ok) + qtbot.addWidget(dialog) + assert not dialog.name_edit.isReadOnly() and dialog.delete_button.isHidden() + dialog.name_edit.setText('yellow') + assert dialog.values() == ('yellow', Kind(marker_a=100, box_a=100)) + + # test_delete_only_when_editing: an existing kind can be deleted, a new one cannot + def test_delete_only_when_editing(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, 'cage', Kind(marker_a=600), [], _ok) + qtbot.addWidget(dialog) + assert not dialog.delete_button.isHidden() + dialog.delete_button.click() + assert dialog.result() == DELETED + + # test_rename: an existing kind's name can be changed, but not to another kind's + def test_rename(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, 'cage', Kind(marker_a=600), ['green'], _ok) + qtbot.addWidget(dialog) + assert not dialog.name_edit.isReadOnly() + dialog.name_edit.setText('green') + dialog.accept() + assert 'already exists' in dialog.error.text() and dialog.result() == 0 + dialog.name_edit.setText('cage2') + dialog.accept() + assert dialog.result() == 1 and dialog.values()[0] == 'cage2' + + # test_labels_have_help: every row is titled from the Kind field with a tooltip + def test_labels_have_help(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, None, None, [], _ok) + qtbot.addWidget(dialog) + assert dialog.labels['marker_a'].text() == 'Marker diameter (Å)' and dialog.labels['extract_angpix'].text() == 'Extract pixel size (Å/px)' + assert set(dialog.labels) == {'name', *Kind.model_fields} + assert all(label.toolTip() for label in dialog.labels.values()) + + # test_bad_entries_keep_dialog_open: a bad name, colour or check result is shown and nothing is accepted + def test_bad_entries_keep_dialog_open(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, None, None, [], lambda n, k: 'taken' if n == 'cage' else None) + qtbot.addWidget(dialog) + dialog.name_edit.setText('bad name') + dialog.accept() + assert 'letters' in dialog.error.text() and dialog.result() == 0 + dialog.name_edit.setText('cage') + dialog.colour_edit.setText('notacolour') + dialog.accept() + assert 'not a colour' in dialog.error.text() + dialog.colour_edit.setText('red') + dialog.accept() + assert dialog.error.text() == 'taken' and dialog.result() == 0 + dialog.name_edit.setText('yellow') + dialog.accept() + assert dialog.result() == 1 + + # test_unregistered_estimator_kept: an estimator the registry does not know survives editing + def test_unregistered_estimator_kept(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, 'cage', Kind(marker_a=600, estimator='future_fit'), [], _ok) + qtbot.addWidget(dialog) + assert dialog.values()[1].estimator == 'future_fit' + + # test_tilt_prior_optional: the tilt prior is only written when its box is ticked + def test_tilt_prior_optional(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, 'cage', Kind(marker_a=600), [], _ok) + qtbot.addWidget(dialog) + assert not dialog.tilt_spin.isEnabled() and dialog.values()[1].tilt_prior_deg is None + dialog.tilt_box.setChecked(True) + dialog.tilt_spin.setValue(-15) + assert dialog.values()[1].tilt_prior_deg == -15 + + # test_new_name_must_be_free: adding a kind cannot reuse a name, so it never overwrites one + def test_new_name_must_be_free(self, qtbot: QtBot) -> None: + dialog = KindDialog(None, None, None, ['cage'], _ok) + qtbot.addWidget(dialog) + dialog.name_edit.setText('cage') + dialog.accept() + assert 'already exists' in dialog.error.text() and dialog.result() == 0 diff --git a/tests/gui/test_kinds_tab.py b/tests/gui/test_kinds_tab.py index 9011c6d..8bfa41f 100644 --- a/tests/gui/test_kinds_tab.py +++ b/tests/gui/test_kinds_tab.py @@ -6,11 +6,12 @@ from collections.abc import Callable from pathlib import Path from pytestqt.qtbot import QtBot -from PySide6.QtWidgets import QComboBox # Import internal pickmix objects from pickmix.config import Kind, load_config +from pickmix.gui.kind_dialog import DELETED, KindDialog from pickmix.gui.kinds_tab import KindsTab +from pickmix.model import PickStore, Session from pickmix.project import open_project # _tab: a Kinds tab showing a fresh project; returns the tab and the config path @@ -21,75 +22,188 @@ def _tab(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[KindsTab, Path tab.set_project(open_project(path)) return tab, path +# _tab_with_picks: a tab with cage, green (child of cage) and yellow kinds, a session, and picks of every kind +def _tab_with_picks(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[KindsTab, Path, Session]: + path = make_config() + project = open_project(path) + project.update(kinds={ + 'cage': Kind(marker_a=600, box_a=700), 'green': Kind(marker_a=40, box_a=200, parent='cage'), 'yellow': Kind(marker_a=100, box_a=100), + }) + session = Session(project) + for kind in ('cage', 'cage', 'green', 'yellow'): + session.store.add('Position_1_2', kind, 1.0, 1.0, 1.0) + tab = KindsTab() + qtbot.addWidget(tab) + tab.set_project(project) + tab.set_session(session, None) + return tab, path, session + # TestKindsTab: kinds tab class TestKindsTab: - # test_load: the default cage kind shows and collects back unchanged - def test_load(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # test_lists_kinds: each kind is listed with its values as plain text and an edit button + def test_lists_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab, _ = _tab(qtbot, make_config) - assert tab.table.rowCount() == 1 - assert tab.collect() == tab.project.config.kinds - - # test_unregistered_estimator_kept: an estimator name the registry does not know must survive load and save - def test_unregistered_estimator_kept(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - path = make_config() - project = open_project(path) - project.update(kinds={'cage': Kind(marker_a=600, estimator='future_fit')}) - tab = KindsTab() - qtbot.addWidget(tab) - tab.set_project(project) - combo = tab.table.cellWidget(0, 3) - assert isinstance(combo, QComboBox) and combo.currentText() == 'future_fit' - tab.save() - assert load_config(path).kinds['cage'].estimator == 'future_fit' - - # test_add_edit_save: a new kind edited in the table reaches config.toml in order - def test_add_edit_save(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + assert tab.kind_names() == ['cage'] and 'cage' in tab.edit_buttons + text = tab.details['cage'].text() + assert 'Marker: 600 Å' in text and 'Box: from estimator' in text and 'Estimator: ring' in text and 'Parent: none' in text + + # test_hotkey_defaults: keys start on the kinds in order, the rest are unused + def test_hotkey_defaults(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, _ = _tab(qtbot, make_config) + assert [b.currentText() for b in tab.hotkey_boxes] == ['cage', *['(none)'] * 8] + assert tab.hotkey_boxes[1].itemText(0) == '(none)' and tab.hotkey_boxes[1].count() == 2 + + # test_hotkey_change_saves: choosing a kind for a key saves it and moves the kind off its old key + def test_hotkey_change_saves(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path = _tab(qtbot, make_config) + with qtbot.waitSignal(tab.config_saved): + tab.hotkey_boxes[2].setCurrentText('cage') + assert load_config(path).hotkeys == ['', '', 'cage', *[''] * 6] + assert tab.hotkey_boxes[0].currentText() == '(none)' + + # test_add_kind: a new kind is saved after the others and takes the first free key + def test_add_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab, path = _tab(qtbot, make_config) - tab.add_kind() - tab.table.item(1, 0).setText('yellow') - tab.table.item(1, 1).setText('100') with qtbot.waitSignal(tab.config_saved): - tab.save() - kinds = load_config(path).kinds - assert list(kinds) == ['cage', 'yellow'] - assert kinds['yellow'] == Kind(marker_a=100, box_a=100) + tab._apply('yellow', Kind(marker_a=100, box_a=100)) + config = load_config(path) + assert list(config.kinds) == ['cage', 'yellow'] and config.hotkeys[:3] == ['cage', 'yellow', ''] + assert tab.kind_names() == ['cage', 'yellow'] and tab.hotkey_boxes[1].currentText() == 'yellow' + + # test_edit_kind: the edit button opens the dialog on that kind and OK saves the changes + def test_edit_kind(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + tab, path = _tab(qtbot, make_config) - # test_invalid_not_saved: a bad table leaves the file alone and says why - def test_invalid_not_saved(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # fake_exec: change the marker size and accept + def fake_exec(dialog: KindDialog) -> int: + assert not dialog.adding and dialog.name_edit.text() == 'cage' + dialog.marker_spin.setValue(700) + return 1 + + monkeypatch.setattr(KindDialog, 'exec', fake_exec) + tab.edit_buttons['cage'].click() + assert load_config(path).kinds['cage'].marker_a == 700 + assert 'Marker: 700 Å' in tab.details['cage'].text() + + # test_cancel_changes_nothing: a cancelled dialog leaves the file alone + def test_cancel_changes_nothing(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: tab, path = _tab(qtbot, make_config) before = path.read_text() - tab.add_kind() - tab.table.item(1, 5).setText('missing') - tab.save() - assert 'unknown parent' in tab.status.text() + monkeypatch.setattr(KindDialog, 'exec', lambda dialog: 0) + tab.add_button.click() assert path.read_text() == before - # test_bad_number_and_colour: unreadable cells are reported with the row - def test_bad_number_and_colour(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # test_dialog_check: the dialog refuses a duplicate name and a passthrough with no box before anything is saved + def test_dialog_check(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab, _ = _tab(qtbot, make_config) - tab.table.item(0, 1).setText('abc') - tab.save() - assert tab.status.text().startswith('Not saved: row 1') - tab.table.item(0, 1).setText('600') - tab.table.item(0, 4).setText('notacolour') - tab.save() - assert 'not a colour' in tab.status.text() - - # test_duplicate_name: two rows with one name are rejected - def test_duplicate_name(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + assert tab._problem('cage2', Kind(marker_a=10, box_a=0, estimator='none'), None) is not None + assert tab._problem('cage2', Kind(marker_a=10, box_a=10, parent='missing'), None) is not None + assert tab._problem('cage2', Kind(marker_a=10, box_a=10), None) is None + + # test_delete_kind: deleting removes the kind and frees its key + def test_delete_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path = _tab(qtbot, make_config) + tab._apply('yellow', Kind(marker_a=100, box_a=100)) + tab._delete('yellow') + config = load_config(path) + assert list(config.kinds) == ['cage'] and config.hotkeys == ['cage', *[''] * 8] + + # test_delete_blocked: a kind that is another's parent is not deleted, and says why + def test_delete_blocked(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path = _tab(qtbot, make_config) + tab._apply('green', Kind(marker_a=40, box_a=200, parent='cage')) + before = path.read_text() + tab._delete('cage') + assert tab.status.text().startswith('Not deleted') and path.read_text() == before + + # test_delete_only_kind: the last kind can be deleted, leaving an empty list and unused keys + def test_delete_only_kind(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path = _tab(qtbot, make_config) + tab._delete('cage') + config = load_config(path) + assert config.kinds == {} and config.hotkeys == [''] * 9 + assert tab.kind_names() == [] and [b.currentText() for b in tab.hotkey_boxes] == ['(none)'] * 9 + tab._apply('yellow', Kind(marker_a=100, box_a=100)) + assert list(load_config(path).kinds) == ['yellow'] and tab.hotkey_boxes[0].currentText() == 'yellow' + + # test_rename_follows: a rename keeps the kind's place, hotkey, children and picks, in memory and in picks.csv + def test_rename_follows(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path, session = _tab_with_picks(qtbot, make_config) + tab._apply('gold', Kind(marker_a=600, box_a=700), old='cage') + config = load_config(path) + assert list(config.kinds) == ['gold', 'green', 'yellow'] and config.kinds['green'].parent == 'gold' + assert config.hotkeys[:3] == ['gold', 'green', 'yellow'] + assert [p.kind for p in session.store.all()] == ['gold', 'gold', 'green', 'yellow'] + assert sorted(p.kind for p in PickStore(session.project).all()) == ['gold', 'gold', 'green', 'yellow'] + assert tab.kind_names() == ['gold', 'green', 'yellow'] + + # test_edit_without_rename_keeps_picks: editing values only leaves the picks alone + def test_edit_without_rename_keeps_picks(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, path, session = _tab_with_picks(qtbot, make_config) + tab._apply('cage', Kind(marker_a=650, box_a=700), old='cage') + assert load_config(path).kinds['cage'].marker_a == 650 and session.store.total('cage') == 2 + + # test_rename_to_existing_refused: a kind cannot take another kind's name + def test_rename_to_existing_refused(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab, _, _ = _tab_with_picks(qtbot, make_config) + assert 'already exists' in tab._problem('green', Kind(marker_a=600, box_a=700), 'cage') + + # test_delete_with_picks_asks: picks of the kind go with it after a yes, and stay with a no + def test_delete_with_picks_asks(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + tab, path, session = _tab_with_picks(qtbot, make_config) + asked: list[str] = [] + monkeypatch.setattr(KindsTab, '_confirm', lambda self, text: asked.append(text) or False) + tab._delete('yellow') + assert 'yellow' in asked[0] and '1 picks' in asked[0] and 'yellow' in load_config(path).kinds and session.store.total('yellow') == 1 + monkeypatch.setattr(KindsTab, '_confirm', lambda self, text: True) + tab._delete('yellow') + assert 'yellow' not in load_config(path).kinds and session.store.total('yellow') == 0 + assert sorted(p.kind for p in PickStore(session.project).all()) == ['cage', 'cage', 'green'] + + # test_delete_without_picks_does_not_ask: an empty kind is deleted without a question + def test_delete_without_picks_does_not_ask(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + tab, path, session = _tab_with_picks(qtbot, make_config) + session.store.remove_kind('yellow') + + # fail: asking is a bug here + def fail(self: KindsTab, text: str) -> bool: + raise AssertionError(text) + + monkeypatch.setattr(KindsTab, '_confirm', fail) + tab._delete('yellow') + assert 'yellow' not in load_config(path).kinds + + # test_refused_delete_keeps_picks: a delete the config refuses neither asks nor touches the picks + def test_refused_delete_keeps_picks(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + tab, path, session = _tab_with_picks(qtbot, make_config) + monkeypatch.setattr(KindsTab, '_confirm', lambda self, text: (_ for _ in ()).throw(AssertionError(text))) + tab._delete('cage') + assert tab.status.text().startswith('Not deleted') and session.store.total('cage') == 2 + + # test_details_are_a_vertical_list_with_bold_keys: one key per line, each key bold + def test_details_are_a_vertical_list_with_bold_keys(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab, _ = _tab(qtbot, make_config) - tab.add_kind() - tab.table.item(1, 0).setText('cage') - tab.save() - assert 'duplicate' in tab.status.text() + lines = tab.details['cage'].text().split('
') + assert len(lines) == 7 and all(line.startswith('') and ': ' in line for line in lines) + + # test_kinds_flow_into_columns: a wide block gives one column, a narrow one gives several, filling across then down + def test_kinds_flow_into_columns(self, qtbot: QtBot, make_config: Callable[..., Path], monkeypatch) -> None: + tab, _, _ = _tab_with_picks(qtbot, make_config) + tab.resize(900, 600) + tab.show() + position = lambda i: tab.grid.getItemPosition(tab.grid.indexOf(tab.cards[i]))[:2] + monkeypatch.setattr('pickmix.gui.kinds_tab._CARD_WIDTH', 10000) + tab._columns = 0 + tab._reflow() + assert tab._columns == 1 and [position(i) for i in range(3)] == [(0, 0), (1, 0), (2, 0)] + monkeypatch.setattr('pickmix.gui.kinds_tab._CARD_WIDTH', 100) + tab._reflow() + assert tab._columns >= 3 and [position(i) for i in range(3)] == [(0, 0), (0, 1), (0, 2)] - # test_move_and_remove: Up swaps rows, Remove drops the selection - def test_move_and_remove(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # test_add_button_is_on_the_right: the Add button sits after the heading's stretch + def test_add_button_is_on_the_right(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: tab, _ = _tab(qtbot, make_config) - tab.add_kind() - tab.table.selectRow(1) - tab._move(-1) - assert list(tab.collect()) == ['kind1', 'cage'] - tab.table.selectRow(0) - tab.remove_selected() - assert list(tab.collect()) == ['cage'] + tab.resize(900, 600) + tab.show() + qtbot.waitUntil(lambda: tab.add_button.isVisible()) + assert tab.add_button.mapTo(tab, tab.add_button.rect().topRight()).x() > tab.scroll.mapTo(tab, tab.scroll.rect().center()).x() diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index 5f56ff2..c939806 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -27,7 +27,7 @@ def test_kinds_tab_follows_project(self, qtbot: QtBot, make_config: Callable[... window = MainWindow() qtbot.addWidget(window) window.project_tab.set_project(open_project(make_config())) - assert window.kinds_tab.table.item(0, 0).text() == 'cage' + assert window.kinds_tab.kind_names() == ['cage'] # test_step_only_on_pick_tab: Enter steps tomograms on the Pick tab and nowhere else def test_step_only_on_pick_tab(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: @@ -72,3 +72,40 @@ def test_average_pane_wired(self, qtbot: QtBot, make_config: Callable[..., Path] assert window.kinds_tab.average.kind_box.count() == 1 window.service.shutdown() window.loader.shutdown() + + # test_dock_only_on_pick_tab: the tomogram dock follows the Pick tab + def test_dock_only_on_pick_tab(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + assert window.dock_widget.isHidden() + window.project_tab.set_project(open_project(make_config())) + assert window.dock_widget.isHidden() + window.tabs.setCurrentWidget(window.pick_tab) + assert not window.dock_widget.isHidden() + window.tabs.setCurrentWidget(window.review_tab) + assert window.dock_widget.isHidden() + window.service.shutdown() + window.loader.shutdown() + + # test_close_project: closing re-locks the other tabs and drops the session + def test_close_project(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(make_config())) + window.project_tab.close_button.click() + assert window.project is None and window.session is None + assert [window.tabs.isTabEnabled(i) for i in range(4)] == [True, False, False, False] + + # test_no_kinds: a project with every kind deleted still opens, and every tab copes + def test_no_kinds(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + path = make_config() + project = open_project(path) + project.update(kinds={}) + window = MainWindow() + qtbot.addWidget(window) + window.project_tab.set_project(open_project(path)) + for tab in (window.pick_tab, window.review_tab, window.kinds_tab): + window.tabs.setCurrentWidget(tab) + assert window.kinds_tab.kind_names() == [] and window.pick_tab.active is None + window.service.shutdown() + window.loader.shutdown() diff --git a/tests/gui/test_pick_tab.py b/tests/gui/test_pick_tab.py index f46c4cc..e0d7bcc 100644 --- a/tests/gui/test_pick_tab.py +++ b/tests/gui/test_pick_tab.py @@ -6,6 +6,8 @@ import pytest from collections.abc import Callable, Iterator from pathlib import Path +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHeaderView from pytestqt.qtbot import QtBot # Import internal pickmix objects @@ -100,7 +102,7 @@ def test_scroll_z(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: def test_hide_kind(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, session = setup() session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) - tab.kind_boxes['cage'].setChecked(False) + tab.kind_toggles['cage'].setChecked(False) assert tab.canvas.marker_ids() == [] and tab.table.rowCount() == 1 # test_navigation_clears_selection: a new tomogram starts with no selection and its own picks @@ -148,3 +150,54 @@ def test_normals_and_noparent(self, qtbot: QtBot, setup: Callable[..., tuple[Pic qtbot.waitUntil(lambda: tab.canvas.normal_visible(near.id), timeout=5000) qtbot.waitUntil(lambda: 'noparent' in {tab.table.item(r, 5).text() for r in range(tab.table.rowCount())}, timeout=5000) assert [tab.table.item(r, 5).text() for r in range(3)] == ['', '', 'noparent'] + + # test_hotkeys_follow_config: keys choose the kind the config puts on them, and buttons show their number + def test_hotkeys_follow_config(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + session.project.update(hotkeys=['', 'green', 'cage']) + tab.refresh_kinds() + tab._pick_kind(0) + assert tab.active == 'cage' + tab._pick_kind(1) + assert tab.active == 'green' + tab._pick_kind(2) + assert tab.active == 'cage' + assert tab.kind_buttons['green'].text() == '(2) green' and tab.kind_buttons['yellow'].text() == 'yellow' + + # test_no_kinds: with every kind deleted there is no active kind and a click adds nothing + def test_no_kinds(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + session.project.update(kinds={}) + tab.refresh_kinds() + assert tab.active is None and tab.kind_buttons == {} + tab.canvas.add_requested.emit(1.0, 1.0) + assert session.store.all() == [] + tab._pick_kind(0) + assert tab.active is None + + # test_title_is_name_only: the title is the tomogram name, with the navigation help kept below + def test_title_is_name_only(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + assert tab.title.text() == session.current and tab.title.font().bold() + assert 'Enter' in tab.help.text() + + # test_hotkeys_row: the key and name label chooses the kind, the eye icon toggles it and starts checked + def test_hotkeys_row(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert tab.kind_buttons['yellow'].text() == '(2) yellow' + assert all(t.isChecked() for t in tab.kind_toggles.values()) + assert tab.kind_buttons['yellow'].minimumWidth() > 0 + tab.kind_toggles['yellow'].click() + assert 'yellow' in tab.hidden and not tab.kind_toggles['yellow'].isChecked() + tab._pick_kind(1) + assert tab.active == 'yellow' + + # test_layout: Z is a vertical scrollbar and image and overlay controls sit in the settings pane + def test_layout(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + assert tab.z_slider.orientation() == Qt.Orientation.Vertical + for widget in (tab.lo_slider, tab.hi_slider, tab.invert_box, tab.auto_button, tab.fit_button, tab.rings_box, tab.boxes_box, tab.normals_box): + assert tab.settings.isAncestorOf(widget) + assert not tab.settings.isAncestorOf(tab.table) and not tab.settings.isAncestorOf(tab.z_slider) + header = tab.table.horizontalHeader() + assert header.sectionResizeMode(0) == QHeaderView.ResizeMode.ResizeToContents and header.sectionResizeMode(1) == QHeaderView.ResizeMode.Stretch diff --git a/tests/gui/test_project_tab.py b/tests/gui/test_project_tab.py index 1b9f6cf..e6b8e5a 100644 --- a/tests/gui/test_project_tab.py +++ b/tests/gui/test_project_tab.py @@ -54,3 +54,38 @@ def test_save_settings(self, qtbot: QtBot, make_config: Callable[..., Path]) -> assert config.source == 'raw' assert config.ring.radius_step_a == 50.0 assert config.ring.radius_range_a == (150.0, 500.0) + + # test_form_labels: parameters show their title and explain themselves on hover + def test_form_labels(self, qtbot: QtBot) -> None: + tab = ProjectTab() + qtbot.addWidget(tab) + label = tab.ring_form.labels['radius_range_a'] + assert label.text() == 'Radius range (Å)' and label.toolTip() + assert len(tab.ring_form._boxes['radius_range_a']) == 2 + + # test_ring_section_collapsed: ring parameters are hidden until the section is opened + def test_ring_section_collapsed(self, qtbot: QtBot) -> None: + tab = ProjectTab() + qtbot.addWidget(tab) + assert tab.ring_form.isHidden() + tab.ring_section.toggle.setChecked(True) + assert not tab.ring_form.isHidden() + + # test_settings_width_fixed: opening the ring section does not change settings pane width + def test_settings_width_fixed(self, qtbot: QtBot) -> None: + tab = ProjectTab() + qtbot.addWidget(tab) + closed = tab.settings.minimumSizeHint().width() + tab.ring_section.toggle.setChecked(True) + assert tab.settings.minimumSizeHint().width() == closed + + # test_start_page_and_close: start page shows until a project opens, and closing returns to it + def test_start_page_and_close(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + tab = ProjectTab() + qtbot.addWidget(tab) + assert tab.stack.currentIndex() == 0 + tab.set_project(open_project(make_config())) + assert tab.stack.currentIndex() == 1 + with qtbot.waitSignal(tab.project_closed): + tab.close_button.click() + assert tab.stack.currentIndex() == 0 and tab.project is None diff --git a/tests/gui/test_style.py b/tests/gui/test_style.py new file mode 100644 index 0000000..7078855 --- /dev/null +++ b/tests/gui/test_style.py @@ -0,0 +1,67 @@ +''' +pickmix: tests for shared GUI styling +''' + +# Import external dependencies +import pytest +from pytestqt.qtbot import QtBot +from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget + +# Import internal pickmix objects +from pickmix.gui.kinds_tab import KindsTab +from pickmix.gui.pick_tab import PickTab +from pickmix.gui.project_tab import ProjectTab +from pickmix.gui.review_tab import ReviewTab +from pickmix.gui.style import MARGIN, SPACING, body_pt, form_label, heading, help_marker, hint, icon, standardise, tidy, title + +# TestStyle: shared text levels and spacing +class TestStyle: + # test_levels: title is larger than heading, heading larger than body, body larger than hint + def test_levels(self, qtbot: QtBot) -> None: + body = QLabel('x') + sizes = [w.font().pointSizeF() for w in (title('x'), heading('x'), body, hint('x'))] + assert sizes == sorted(sizes, reverse=True) and len(set(sizes)) == 4 + assert title('x').font().bold() and not hint('x').font().bold() + + # test_tidy: margins and spacing come from the constants + def test_tidy(self, qtbot: QtBot) -> None: + layout = QVBoxLayout() + tidy(layout) + assert layout.contentsMargins().left() == MARGIN and layout.spacing() == SPACING + + # test_form_label: a label column entry is the title, a marker and a colon, with the tip on the title and the marker + def test_form_label(self, qtbot: QtBot) -> None: + head = form_label('Radius (Å)', 'How big.') + qtbot.addWidget(head) + labels = head.findChildren(QLabel) + assert labels[0].text() == 'Radius (Å)' and labels[0].toolTip() == 'How big.' + assert labels[1].toolTip() == 'How big.' and labels[-1].text() == ':' + + # test_help_marker_falls_back: without the icon file the marker reads (?) + def test_help_marker_falls_back(self, qtbot: QtBot, monkeypatch) -> None: + monkeypatch.setattr('pickmix.gui.style.icon', lambda stem: None) + marker = help_marker('tip') + assert marker.text() == '(?)' and marker.toolTip() == 'tip' + + # test_icon_missing: an icon with no file is None so callers can fall back to text + def test_icon_missing(self) -> None: + assert icon('no_such_icon') is None + + # test_standardise: widgets with a smaller native font are given the body size + def test_standardise(self, qtbot: QtBot) -> None: + app = QApplication.instance() + standardise(app) + try: + sheet = app.styleSheet() + assert all(name in sheet for name in ('QGroupBox', 'QToolButton', 'QHeaderView')) + assert f'font-size: {body_pt()}pt' in sheet + finally: + app.setStyleSheet('') + + # test_tabs_use_shared_spacing: every tab's top-level layout uses the shared margin and spacing + @pytest.mark.parametrize('cls', [ProjectTab, KindsTab, PickTab, ReviewTab]) + def test_tabs_use_shared_spacing(self, qtbot: QtBot, cls: type[QWidget]) -> None: + tab = cls() + qtbot.addWidget(tab) + layout = tab.layout() + assert layout.contentsMargins().left() == MARGIN and layout.spacing() == SPACING diff --git a/tests/gui/test_tomo_dock.py b/tests/gui/test_tomo_dock.py index 431919a..c500022 100644 --- a/tests/gui/test_tomo_dock.py +++ b/tests/gui/test_tomo_dock.py @@ -8,7 +8,8 @@ from pytestqt.qtbot import QtBot # Import internal pickmix objects -from pickmix.gui.tomo_dock import TomoDock +from pickmix.gui.tomo_dock import COLOUR_CURRENT, COLOUR_PENDING, COLOUR_VISITED, TomoDock + from pickmix.model import Session from pickmix.project import open_project @@ -25,20 +26,32 @@ def _dock(qtbot: QtBot, make_config: Callable[..., Path]) -> tuple[TomoDock, Ses # TestTomoDock: tomogram dock class TestTomoDock: - # test_lines: current and opened markers, numbers, names and counts + # test_lines: number, name and picks, no markers def test_lines(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: dock, session = _dock(qtbot, make_config) session.store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) - assert dock.list.item(0).text().split() == ['>', '*', '1', 'Position_1_2', '1'] + assert dock.list.item(0).text().split() == ['1', 'Position_1_2', '1'] assert dock.list.item(1).text().split() == ['2', 'Position_1_3', '0'] - # test_click_goes: clicking a line shows that tomogram and moves the marker - def test_click_goes(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + # test_state_colours: current blue and bold, opened green, not yet opened orange + def test_state_colours(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: dock, session = _dock(qtbot, make_config) + assert dock.list.item(0).foreground().color() == COLOUR_CURRENT and dock.list.item(0).font().bold() + assert dock.list.item(1).foreground().color() == COLOUR_PENDING and not dock.list.item(1).font().bold() dock._clicked(dock.list.item(2)) assert session.current == 'Other_9' - assert dock.list.item(2).text().startswith('> *') - assert dock.list.item(0).text().startswith(' *') + assert dock.list.item(2).foreground().color() == COLOUR_CURRENT + assert dock.list.item(0).foreground().color() == COLOUR_VISITED and not dock.list.item(0).font().bold() + assert dock.list.item(1).foreground().color() == COLOUR_PENDING + + # test_visited_label: visited count sits above the search box, with no per-kind totals + def test_visited_label(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: + dock, session = _dock(qtbot, make_config) + assert dock.visited.text() == 'Visited 1/3' + session.go(1) + assert dock.visited.text() == 'Visited 2/3' + assert not hasattr(dock, 'totals') + # test_jump_box: a name jumps and clears the box, no match keeps the text def test_jump_box(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: @@ -49,9 +62,3 @@ def test_jump_box(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: dock.jump_edit.setText('zzz') dock.jump_edit.returnPressed.emit() assert session.current == 'Other_9' and dock.jump_edit.text() == 'zzz' - - # test_totals: visited count and per-kind totals - def test_totals(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: - dock, session = _dock(qtbot, make_config) - session.store.add('Position_1_3', 'cage', 1.0, 1.0, 1.0) - assert dock.totals.text().split() == ['visited', '1/3', 'cage', '1'] diff --git a/tests/test_config.py b/tests/test_config.py index d636dc8..643bc80 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ from pydantic import ValidationError # Import internal pickmix objects -from pickmix.config import Config, Kind, load_config, save_config +from pickmix.config import Config, Kind, RingParams, load_config, save_config # _config: a config with absolute paths, so it survives a save and load unchanged, and the given kinds def _config(**kinds: Kind) -> Config: @@ -66,3 +66,45 @@ def test_none_needs_box(self) -> None: def test_bad_kind_name(self) -> None: with pytest.raises(ValidationError, match='letters'): _config(**{'bad name': Kind(marker_a=1, box_a=1)}) + + # test_ring_params_documented: every ring parameter has a form label and a tooltip + def test_ring_params_documented(self) -> None: + for name, field in RingParams.model_fields.items(): + assert field.title and field.description, name + + # test_default_hotkeys: no hotkeys means the first nine kinds in table order, padded with unused keys + def test_default_hotkeys(self) -> None: + assert _config().hotkeys == ['cage', *[''] * 8] + kinds = {f'k{i}': Kind(marker_a=10, box_a=10) for i in range(11)} + assert _config(**kinds).hotkeys == [f'k{i}' for i in range(9)] + + # test_hotkeys_round_trip: chosen hotkeys survive a save and load, unused keys included + def test_hotkeys_round_trip(self, tmp_path: Path) -> None: + config = _config(cage=Kind(marker_a=600, estimator='ring'), yellow=Kind(marker_a=100, box_a=100)) + config = config.replace(hotkeys=['', 'yellow', 'cage']) + save_config(config, tmp_path / 'config.toml') + assert load_config(tmp_path / 'config.toml').hotkeys == ['', 'yellow', 'cage', *[''] * 6] + + # test_bad_hotkeys: unknown kinds, repeats and too many keys are rejected + def test_bad_hotkeys(self) -> None: + config = _config() + for keys, text in ((['nope'], 'unknown kind'), (['cage', 'cage'], 'one hotkey'), (['cage'] + [''] * 9, 'at most')): + with pytest.raises(ValidationError, match=text): + config.replace(hotkeys=keys) + + # test_replace_kinds_resets_hotkeys: swapping the kinds without hotkeys gives the default hotkeys for the new kinds + def test_replace_kinds_resets_hotkeys(self) -> None: + config = _config().replace(kinds={'yellow': Kind(marker_a=100, box_a=100)}) + assert config.hotkeys == ['yellow', *[''] * 8] + + # test_no_kinds: a config may have no kinds, which saves and loads as no kinds rather than the default + def test_no_kinds(self, tmp_path: Path) -> None: + config = Config(raw_dir=Path('/r'), tomograms_star=Path('/t.star'), out_dir=Path('/out'), kinds={}) + assert config.kinds == {} and config.hotkeys == [''] * 9 + save_config(config, tmp_path / 'config.toml') + assert load_config(tmp_path / 'config.toml') == config + + # test_kind_documented: every kind field has a dialog label and a tooltip + def test_kind_documented(self) -> None: + for name, field in Kind.model_fields.items(): + assert field.title and field.description, name diff --git a/tests/test_estimates.py b/tests/test_estimates.py index b4195b0..83faeb6 100644 --- a/tests/test_estimates.py +++ b/tests/test_estimates.py @@ -92,3 +92,12 @@ def test_after_shutdown(self, service: EstimateService) -> None: pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) time.sleep(0.1) assert service.store.state(pick) == 'missing' + + # test_rename_kind_keeps_fits: a renamed kind keeps its fits instead of being estimated again + def test_rename_kind_keeps_fits(self, service: EstimateService) -> None: + pick = service.session.store.add(NAME, 'yellow', 3.0, 2.0, 1.0) + _wait(lambda: service.store.state(pick) == 'ok') + service.session.project.update(kinds={'gold': Kind(marker_a=100, box_a=100)}) + service.store.rename_kind('yellow', 'gold') + service.session.store.rename_kind('yellow', 'gold') + assert pick.kind == 'gold' and service.store.get(pick) is not None and service.pending == 0 diff --git a/tests/test_model.py b/tests/test_model.py index 528c071..b86b549 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -108,3 +108,23 @@ def test_goto_listener(self, make_config: Callable[..., Path]) -> None: session.goto_listeners.append(lambda: heard.append(session.current)) session.step(1) assert heard == ['Position_1_3'] + + # test_rename_kind: every pick of a kind moves to the new name, is saved once and is heard once + def test_rename_kind(self, make_config: Callable[..., Path]) -> None: + store = _store(make_config) + store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) + store.add('Position_1_2', 'cage', 2.0, 1.0, 1.0) + store.add('Position_1_2', 'other', 3.0, 1.0, 1.0) + heard: list[int] = [] + store.listeners.append(lambda: heard.append(1)) + assert store.rename_kind('cage', 'ring') == 2 and store.rename_kind('nothing', 'x') == 0 + assert [p.kind for p in store.all()] == ['ring', 'ring', 'other'] and heard == [1] + assert sorted(p.kind for p in PickStore(store.project).all()) == ['other', 'ring', 'ring'] + + # test_remove_kind: every pick of a kind goes, the rest stay, and the file follows + def test_remove_kind(self, make_config: Callable[..., Path]) -> None: + store = _store(make_config) + store.add('Position_1_2', 'cage', 1.0, 1.0, 1.0) + store.add('Position_1_2', 'other', 3.0, 1.0, 1.0) + assert store.remove_kind('cage') == 1 and store.remove_kind('cage') == 0 + assert [p.kind for p in PickStore(store.project).all()] == ['other'] diff --git a/uv.lock b/uv.lock index de5a129..1332835 100644 --- a/uv.lock +++ b/uv.lock @@ -137,7 +137,7 @@ wheels = [ [[package]] name = "pickmix" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "imageio" },