diff --git a/pyproject.toml b/pyproject.toml index 2d76a35..ffda14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pickmix" -version = "0.4.0" +version = "0.4.1" description = "Multi-kind particle picking GUI for cryo-ET sub-tomogram averaging workflows." readme = "README.md" authors = [ diff --git a/src/pickmix/gui/components/range_slider.py b/src/pickmix/gui/components/range_slider.py index f393c4f..b9c230d 100644 --- a/src/pickmix/gui/components/range_slider.py +++ b/src/pickmix/gui/components/range_slider.py @@ -4,7 +4,7 @@ # Import external dependencies from typing import Literal -from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal +from PySide6.QtCore import QEvent, QPointF, QRectF, QSize, Qt, Signal from PySide6.QtGui import QKeyEvent, QMouseEvent, QPainter, QPaintEvent, QPen from PySide6.QtWidgets import QSizePolicy, QWidget @@ -50,6 +50,13 @@ def set_values(self, low: int, high: int, *, emit: bool = False) -> None: if changed and emit: self.valueChanged.emit(low, high) + # event: claim up and down keys before a window shortcut can + def event(self, event: QEvent) -> bool: + if event.type() == QEvent.Type.ShortcutOverride and isinstance(event, QKeyEvent) and event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down): + event.accept() + return True + return super().event(event) + # keyPressEvent: Left and Right move the active handle, Up and Down switch handle, PageUp and PageDown move by a tenth def keyPressEvent(self, event: QKeyEvent) -> None: small, large = max(1, (self._max - self._min) // 100), max(1, (self._max - self._min) // 10) diff --git a/src/pickmix/gui/tabs/pick_tab.py b/src/pickmix/gui/tabs/pick_tab.py index ab54023..56ee998 100644 --- a/src/pickmix/gui/tabs/pick_tab.py +++ b/src/pickmix/gui/tabs/pick_tab.py @@ -6,6 +6,7 @@ import numpy as np from concurrent.futures import Future from PySide6.QtCore import Qt +from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QButtonGroup, QCheckBox, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, QScrollBar, QSlider, QSplitter, QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, QWidget, @@ -51,7 +52,7 @@ def __init__(self) -> None: self._range = (0.0, 1.0) self.canvas = SliceCanvas() self.title = title() - self.help = hint('Enter: next tomogram. Shift+Enter: previous tomogram.') + self.help = hint('Enter: next tomogram. Shift+Enter: previous tomogram. Up/Down: change Z slice; Shift+Up/Down: change Z slice by 10.') self.status = QLabel() self.match_pane = MatchPane() self.tomo = TomoPane() @@ -184,6 +185,8 @@ def __init__(self) -> None: self.canvas.move_requested.connect(self._move) self.canvas.marker_clicked.connect(self._clicked) self.canvas.z_scrolled.connect(lambda steps: self.z_slider.setValue(self.z_slider.value() + steps)) + for keys, step in (('Up', 1), ('Down', -1), ('Shift+Up', 10), ('Shift+Down', -10)): + QShortcut(QKeySequence(keys), self, context=Qt.ShortcutContext.WidgetWithChildrenShortcut).activated.connect(lambda step=step: self._step_z(step)) self.table.cellClicked.connect(self._row_clicked) self.match_pane.scan_requested.connect(self._scan) self.match_pane.stop_requested.connect(self._stop_scan) @@ -396,6 +399,10 @@ def _open_done(self, name: str, future: Future[Opened]) -> None: if self.session.index + 1 < len(self.session.names): self.loader.prefetch(self.session.names[self.session.index + 1]) + # _step_z: move the slice by steps + def _step_z(self, steps: int) -> None: + self.z_slider.setValue(self.z_slider.value() + steps) + # _show_z: show slice z, from the cache or after reading it in the background def _show_z(self, z: int) -> None: if self.opened is None or self.loader is None: diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index c0fd225..30e655a 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -226,7 +226,8 @@ def test_pick_keys_are_window_wide(self, qtbot: QtBot) -> None: qtbot.addWidget(window) keys = {s.key().toString(): s for s in window.findChildren(QShortcut)} assert all(str(i) in keys for i in range(1, 10)) and 'Del' in keys and 'Backspace' in keys and 'A' in keys - assert all(s.context() == Qt.ShortcutContext.WindowShortcut for s in keys.values()) + wanted = [*(str(i) for i in range(1, 10)), 'Del', 'Backspace', 'A'] + assert all(keys[k].context() == Qt.ShortcutContext.WindowShortcut for k in wanted) # test_hotkey_and_delete_from_other_focus: with focus off the canvas, a hotkey still picks the kind and delete still removes the pick def test_hotkey_and_delete_from_other_focus(self, qtbot: QtBot, make_config: Callable[..., Path]) -> None: diff --git a/tests/gui/test_pick_tab.py b/tests/gui/test_pick_tab.py index 3c7003a..3b903f2 100644 --- a/tests/gui/test_pick_tab.py +++ b/tests/gui/test_pick_tab.py @@ -108,6 +108,51 @@ def test_scroll_z(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab.canvas.z_scrolled.emit(-9) assert tab.z == 0 + # test_arrow_keys_scroll_z: Up and Down move one slice and Shift ten, clamped to the tomogram + def test_arrow_keys_scroll_z(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + tab.show() + qtbot.waitExposed(tab) + tab.activateWindow() + qtbot.waitUntil(tab.isActiveWindow, timeout=3000) + tab.canvas.setFocus() + qtbot.keyClick(tab.canvas, Qt.Key.Key_Down) + assert tab.z == 1 + qtbot.keyClick(tab.canvas, Qt.Key.Key_Up) + qtbot.keyClick(tab.canvas, Qt.Key.Key_Up) + assert tab.z == 3 + qtbot.keyClick(tab.canvas, Qt.Key.Key_Up) + assert tab.z == 3 + qtbot.keyClick(tab.canvas, Qt.Key.Key_Down, Qt.KeyboardModifier.ShiftModifier) + assert tab.z == 0 + + # test_arrow_keys_from_table: arrows scroll z even when the pick table has focus + def test_arrow_keys_from_table(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, session = setup() + tab.pick_kind(0) + tab._add(1.0, 1.0) + tab.show() + qtbot.waitExposed(tab) + tab.activateWindow() + qtbot.waitUntil(tab.isActiveWindow, timeout=3000) + tab.table.setFocus() + before = tab.z + qtbot.keyClick(tab.table, Qt.Key.Key_Up) + assert tab.z == before + 1 + assert session.store.total() == 1 + + # test_arrow_keys_leave_contrast_slider: Up and Down still switch the active handle of the contrast slider + def test_arrow_keys_leave_contrast_slider(self, qtbot: QtBot, setup: Callable[..., tuple[PickTab, Session]]) -> None: + tab, _ = setup() + tab.show() + qtbot.waitExposed(tab) + tab.activateWindow() + qtbot.waitUntil(tab.isActiveWindow, timeout=3000) + tab.contrast.setFocus() + before = tab.z + qtbot.keyClick(tab.contrast, Qt.Key.Key_Up) + assert tab.z == before + # test_delete_needs_selection: Delete is greyed until a pick is selected and again once it is gone def test_delete_needs_selection(self, setup: Callable[..., tuple[PickTab, Session]]) -> None: tab, _ = setup() diff --git a/uv.lock b/uv.lock index ad5b491..9050010 100644 --- a/uv.lock +++ b/uv.lock @@ -137,7 +137,7 @@ wheels = [ [[package]] name = "pickmix" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "imageio" },