Source code for spacr.qt.widgets.plate_map_picker

"""Select one or more wells from a plate-shaped grid.

Selections are parsed and serialized through :mod:`spacr.well_spec`, so rows,
columns, and individual wells use the same validated vocabulary in the GUI and
headless APIs. Rectangular selection is exposed through :meth:`select_region`
for mouse-drag handling and programmatic use.

ONE GRID, NOT THREE THINGS NEAR EACH OTHER. The column numbers, the row
letters and the wells all live in a single :class:`QGridLayout`, every item
locked to the same square cell, so a column number sits over its column and a
row letter beside its row at every window size. The grid takes the width and
height its square cells need and leaves the rest to a trailing empty row and
column, rather than dividing whatever the window happens to give it -- which
is what pulls the labels away from the wells they name.

Each cell states that square in its OWN style sheet rather than through
``setFixedSize``, which does not survive being polished under the application
stylesheet. See :func:`_locked_square`.
"""
from __future__ import annotations

from functools import lru_cache
from typing import Optional, Set, Tuple

from PySide6.QtCore import QPoint, Qt, QTimer
from PySide6.QtWidgets import (QDialog, QDialogButtonBox, QGridLayout,
                               QHBoxLayout, QInputDialog, QLabel,
                               QPushButton, QScrollArea, QVBoxLayout, QWidget)

from ...well_spec import (DEFAULT_LAYOUT, LAYOUTS, WellSpecError, parse,
                          row_label, shape, to_text, well_label)

#: The blue a chosen well is filled with, and the ground it sits on.
CHOSEN = "#4472C4"
EMPTY = "rgba(255, 255, 255, 0.06)"

#: The side of a well AT 100% FONT SCALE, in pixels, and of every other cell
#: of the plate -- the row letters and the column numbers are pitched to the
#: same square. A well is a SQUARE at every window size: a plate map is a
#: picture of a physical object, and a well stretched into a rectangle reads
#: as a grid of something else. Pinning the side is what makes the grid ask
#: for the space it needs instead of dividing what it is handed.
#:
#: A BASE, NOT THE ANSWER: call :func:`well_side` for the number to use.
WELL_SIDE = 22

#: The rim drawn around a well, in pixels per side.
WELL_RIM = 1

#: How often, in milliseconds, a drag held at the edge of the plate's scroll
#: area scrolls it one step further.
AUTOSCROLL_INTERVAL_MS = 30

#: The fastest a held drag scrolls, in pixels per step. The speed grows with
#: how far into the edge band -- or past the edge -- the pointer is, so a
#: pointer just inside the band creeps and one flung past the edge races.
AUTOSCROLL_MAX_STEP = 40


[docs] def well_side() -> int: """The side of a well right now, in pixels, at the user's font scale. THE GLYPHS GREW AND THE BOX DID NOT. `WELL_SIDE` was read as a raw pixel constant, so at the 200% font scale a two-digit column header wanted 30 px of width inside a 22 px cell and every one of columns 10 to 24 had its number cut in half -- 15 clipped headers in every language, which is what `KNOWN_OFFENDERS` recorded against PlateMapPicker. `scaled_px` is the house mechanism for exactly this: the font scale is one preference with two consumers, the stylesheet that sets font sizes and this, which sizes anything set in pixels from Python. A control tuned to match a text width has to go through it or the two drift apart. READ AT CONSTRUCTION, not cached, because the preference can change while the application is running and a plate built afterwards should be pitched to the new scale. """ from ..preferences import scaled_px return scaled_px(WELL_SIDE)
def _locked_square(rim: int = 0) -> str: """QSS pinning one cell of the plate to the square the grid is pitched at. STATED IN THE STYLE SHEET RATHER THAN THROUGH ``setFixedSize``, and that is not a style choice. The application sheet carries ``QPushButton { min-height: 22px; padding: 8px 12px }``, and ``QStyleSheetStyle`` turns a geometry rule into a real ``setMinimumHeight`` on the widget when it polishes it -- 40 px, once the padding and the rim are added back on -- which OVERWRITES the minimum ``setFixedSize`` had written. The maximum it wrote survives, so the well is left with a minimum taller than its maximum, and Qt resolves that in favour of the minimum: a 22 x 40 well in a grid pitched for squares, with the column numbers riding 18 px above the columns they name. A widget's own sheet beats the application's, so the only durable way to keep the square is to ask for it in the language that took it away. :param rim: the border width the same rule draws, in pixels per side. Qt adds the border, the padding and the margin back on to whatever ``min-`` and ``max-`` ask for, so the numbers below are the CONTENT box: padding and margin are zeroed here, and the rim is the whole of the rest of the difference from :func:`well_side`. """ box = well_side() - 2 * int(rim) return (f"padding: 0px; margin: 0px; " f"min-width: {box}px; max-width: {box}px; " f"min-height: {box}px; max-height: {box}px;") @lru_cache(maxsize=8) def _well_sheet(chosen: bool, side: int) -> str: """The sheet a well wears, cached by state and by the side it is pitched at. CACHED, BECAUSE A PLATE REPAINTS EVERY WELL. A 1536-well plate rebuilds every one of its wells' sheets on every selection change, and the string does not depend on which well it is going on -- which is why this was a module-level dict built once at import. BUT NOT BUILT AT IMPORT, because the side follows the font scale now. A dict comprehension at module level bakes whichever scale happened to be active when the module was first imported, and the result was a plate whose HEADERS scaled and whose WELLS did not: 44 px letters over 22 px wells at the 200% scale, with every column number riding clear of the column it names. Keying the cache by `side` keeps the repaint cheap and lets the answer change when the preference does. :param chosen: whether the well is selected. :param side: the side to pitch it at, from :func:`well_side`. :returns: the style sheet, as QSS. """ colour = CHOSEN if chosen else EMPTY box = side - 2 * WELL_RIM return (f"QPushButton {{ background: {colour}; " f"border: {WELL_RIM}px solid rgba(255,255,255,0.18); " f"border-radius: 3px; " f"padding: 0px; margin: 0px; " f"min-width: {box}px; max-width: {box}px; " f"min-height: {box}px; max-height: {box}px; }}") class _Header(QLabel): """A row letter or a column number, locked to a cell of the plate. PART OF THE GRID, NOT A CAPTION BESIDE IT. It takes the same square as a well, so the header row is exactly one cell tall and the header column exactly one cell wide however tall the theme's font makes a label -- which is what keeps a number over its column and a letter beside its row at every window size and every layout. Its sheet says nothing about colour, so the theme still paints it. :param text: the row letter or column number this header shows. :param parent: parent widget; ownership only. """ def __init__(self, text: str, parent=None): """Build the header, locked to the same square as a well.""" super().__init__(text, parent) self.setAlignment(Qt.AlignCenter) self.setFixedSize(well_side(), well_side()) self.setStyleSheet("QLabel { border-width: 0px; %s }" % _locked_square()) class _Well(QPushButton): """One well. Checkable, so its state IS the selection.""" def __init__(self, row: int, column: int, parent=None): """Build one well of the plate. :param row: the well's row index, counting from zero. :param column: its column index, counting from zero. :param parent: parent widget. The pair is the well's IDENTITY, not just its position: the picker addresses wells by it, and :func:`well_label` turns it into the ``A01`` name shown in the tooltip. """ super().__init__(parent) self.row, self.column = int(row), int(column) self.setCheckable(True) self.setFixedSize(well_side(), well_side()) self.setToolTip(well_label(row, column)) self._paint() self.toggled.connect(lambda *_: self._paint()) def _picker(self): """The :class:`PlateMapPicker` this well belongs to, or ``None``. FOUND BY ANCESTRY, NEVER BY COUNTING STEPS. A scroll area reparents the widget it is given into its own viewport, so a fixed two-parent walk lands on that viewport, every ``hasattr(picker, "begin_drag")`` guard below reads False, and the drag gesture silently does nothing. """ node = self.parent() while node is not None: if isinstance(node, PlateMapPicker): return node node = node.parent() return None def mousePressEvent(self, event): # noqa: N802 - Qt """Begin a drag-select from this well. :param event: the mouse event; its modifiers decide whether the drag adds to the selection or replaces it. """ picker = self._picker() if picker is not None and hasattr(picker, "begin_drag"): picker.begin_drag(self.row, self.column, event.modifiers()) super().mousePressEvent(event) def mouseMoveEvent(self, event): # noqa: N802 - Qt """Extend the drag-select to the well under the pointer. Reported in GLOBAL coordinates, because the pointer is usually over a different well by now and this one cannot say which. :param event: the mouse event. """ picker = self._picker() if picker is not None and hasattr(picker, "drag_to"): picker.drag_to(self.mapToGlobal(event.position().toPoint())) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): # noqa: N802 - Qt """End a drag-select, or let a plain click through to the toggle. The drag is finished BEFORE the base class runs, which is what emits ``clicked`` and toggles the button: a drag that has already painted the rectangle must not then have its anchor flipped a second time by the click. :param event: the mouse event. """ picker = self._picker() dragged = (picker is not None and hasattr(picker, "finish_drag") and picker.finish_drag()) if dragged: event.accept() return super().mouseReleaseEvent(event) def _paint(self) -> None: """Draw the well in its current state, and re-state its square. THE SQUARE IS REWRITTEN WITH THE COLOUR because they share one sheet: a repaint that dropped the geometry would hand the well straight back to the application sheet's 40 px minimum. See :func:`_locked_square`. """ self.setStyleSheet(_well_sheet(self.isChecked(), well_side()))
[docs] class PlateMapPicker(QDialog): """Display a plate map and return its selection as a well specification. :param value: the wells already chosen, as a well specification. Parsed against ``layout``, so wells outside it are dropped rather than silently kept. :param layout: how many wells the plate has -- one of the sizes :data:`spacr.well_spec.LAYOUTS` knows (6, 12, 24, 96, 384, 1536). Defaults to 384. :param parent: parent widget. """ def __init__(self, value: str = "", layout: int = DEFAULT_LAYOUT, parent: Optional[QWidget] = None): """Build the well picker over a plate layout. :param value: the wells to start selected, in the text form the settings field holds. :param layout: the plate format, in wells. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setWindowTitle("Choose wells") self._layout_size = int(layout) self._wells = {} self._anchor: Optional[Tuple[int, int]] = None self._last_cell: Optional[Tuple[int, int]] = None self._before: Set[Tuple[int, int]] = set() self._adding = False self._dragged = False self._drag_point: Optional[QPoint] = None self._scroll_step = (0, 0) self._autoscroll = QTimer(self) self._autoscroll.setInterval(AUTOSCROLL_INTERVAL_MS) self._autoscroll.timeout.connect(self._autoscroll_tick) outer = QVBoxLayout(self) self._caption = QLabel("", self) self._caption.setObjectName("Muted") outer.addWidget(self._caption) self._holder = QWidget(self) self._grid = QGridLayout(self._holder) self._grid.setSpacing(2) area = QScrollArea(self) area.setWidgetResizable(True) area.setWidget(self._holder) outer.addWidget(area, 1) self._area = area row = QHBoxLayout() row.addStretch(1) self.plate_button = QPushButton("Plate", self) self.plate_button.setToolTip("Choose the plate layout.") self.plate_button.clicked.connect(lambda: self.ask_for_layout()) row.addWidget(self.plate_button) buttons = QDialogButtonBox(self) self.done_button = buttons.addButton("Done", QDialogButtonBox.AcceptRole) self.close_button = buttons.addButton("Close", QDialogButtonBox.RejectRole) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) row.addWidget(buttons) outer.addLayout(row) self.set_layout_size(self._layout_size, keep=value)
[docs] def set_layout_size(self, layout: int, keep: str = "") -> None: """Rebuild the map for a plate layout and retain valid selections. Wells outside the new layout are dropped and counted in the caption, making a layout-induced selection change visible. :param layout: number of wells on the plate: 6, 12, 24, 96, 384 or 1536; any other value raises ``WellSpecError``. """ rows, columns = shape(layout) self._layout_size = int(layout) wanted = self.selection() if not keep else self._read(keep) while self._grid.count(): widget = self._grid.takeAt(0).widget() widget.setParent(None) widget.deleteLater() self._wells = {} for index in range(self._grid.rowCount()): self._grid.setRowStretch(index, 0) for index in range(self._grid.columnCount()): self._grid.setColumnStretch(index, 0) for column in range(1, columns + 1): self._grid.addWidget(_Header(str(column), self._holder), 0, column, Qt.AlignCenter) for row in range(1, rows + 1): self._grid.addWidget(_Header(row_label(row), self._holder), row, 0, Qt.AlignCenter) for column in range(1, columns + 1): well = _Well(row, column, self._holder) well.pressed.connect( lambda r=row, c=column: self._begin(r, c)) well.toggled.connect(lambda *_: self._say()) self._grid.addWidget(well, row, column, Qt.AlignCenter) self._wells[(row, column)] = well self._grid.setColumnMinimumWidth(0, well_side()) self._grid.setRowMinimumHeight(0, well_side()) self._grid.setRowStretch(rows + 1, 1) self._grid.setColumnStretch(columns + 1, 1) kept = {cell for cell in wanted if cell in self._wells} lost = len(wanted) - len(kept) self.set_selection(kept) self._say(lost)
[docs] def ask_for_layout(self, chosen: Optional[int] = None) -> int: """Choose a supported plate size and return the active layout.""" sizes = sorted(LAYOUTS) if chosen is None: current = sizes.index(self._layout_size) \ if self._layout_size in sizes else sizes.index(DEFAULT_LAYOUT) text, ok = QInputDialog.getItem( self, "Plate layout", "How many wells?", [str(size) for size in sizes], current, False) if not ok: return self._layout_size chosen = int(text) self.set_layout_size(int(chosen)) return self._layout_size
def _begin(self, row: int, column: int) -> None: """A press starts a drag; the anchor is where it started.""" self._anchor = (row, column)
[docs] def begin_drag(self, row: int, column: int, modifiers=None) -> None: """Anchor a drag on one well. :param row: zero-based plate row of the pressed well. :param column: zero-based plate column of the pressed well. :param modifiers: the keyboard state at the press. With Ctrl the rectangle ADDS to what is already chosen; without it the drag REPLACES the selection, which is what every other grid in this application does. """ self._anchor = (int(row), int(column)) self._adding = bool(modifiers is not None and (modifiers & Qt.ControlModifier)) self._before = self.selection() self._dragged = False
[docs] def well_at(self, position) -> Optional[Tuple[int, int]]: """The well under a GLOBAL point, or ``None``. Global, because the pointer is over a sibling of the widget that is receiving the events -- the pressed one keeps the grab. :param position: a point in global screen coordinates (a ``QPoint``). """ for cell, well in self._wells.items(): local = well.mapFromGlobal(position) if well.rect().contains(local): return cell return None
[docs] def drag_to(self, position) -> None: """Preview the rectangle from the anchor to the well under ``position``. SHOWN WHILE DRAGGING, because a selection you cannot see until you let go is one you have to undo to correct. A MOVE INSIDE THE PRESSED WELL IS NOT A DRAG. Every real click carries a pixel or two of pointer travel, and drawing a one-well rectangle for it both clears the rest of the selection and leaves the release to fall through as an ordinary click -- which toggles the anchor straight back off, so a wobbled click lands nothing and takes the previous selection with it. AND ONCE IT IS A DRAG IT STAYS ONE. Sweeping away from the anchor and back again ends on the anchor, and treating that as no drag would hand the release to the click and toggle off the well the rectangle had just chosen. :param position: the pointer position in global screen coordinates (a ``QPoint``); nothing happens unless a press has set an anchor well. """ if self._anchor is None: return self._drag_point = QPoint(position) self._scroll_step = self._edge_step(position) if self._scroll_step != (0, 0): if not self._autoscroll.isActive(): self._autoscroll.start() else: self._autoscroll.stop() cell = self._well_nearest(self._within_view(position), position) if cell is None or cell == getattr(self, "_last_cell", None): return if cell == self._anchor and not self._dragged: return self._last_cell = cell self._dragged = True self.set_selection(self._before if self._adding else set()) self.select_region(self._anchor, cell, choosing=True)
[docs] def finish_drag(self) -> bool: """End a drag. Returns whether one actually happened. `True` tells the well to swallow its release: the rectangle is already painted, and letting the click through would toggle the anchor a second time. """ dragged = bool(getattr(self, "_dragged", False)) self._anchor = None self._last_cell = None self._dragged = False self._autoscroll.stop() self._drag_point = None self._scroll_step = (0, 0) if dragged: self._say() return dragged
def _view_rect_global(self): """The scroll area's viewport in global coordinates, or ``None``. ``None`` while the dialog is not on screen: an unshown viewport has a placeholder geometry, and clamping to it would move the pointer. """ viewport = self._area.viewport() if not viewport.isVisible(): return None rect = viewport.rect() return rect.translated(viewport.mapToGlobal(QPoint(0, 0))) def _within_view(self, position) -> QPoint: """``position`` pulled back inside the visible part of the plate. PAST THE EDGE THE POINTER IS OVER A WELL NOBODY CAN SEE. The grid still has geometry out there, clipped by the viewport, so an unclamped lookup would stretch the rectangle to a well that is not on screen; clamped, the rectangle ends on the edge well and grows as the autoscroll brings the next one into view. :param position: the pointer in global coordinates. """ rect = self._view_rect_global() point = QPoint(position) if rect is None or rect.isEmpty(): return point point.setX(min(max(point.x(), rect.left() + 1), rect.right() - 1)) point.setY(min(max(point.y(), rect.top() + 1), rect.bottom() - 1)) return point def _well_nearest(self, clamped, position) -> Optional[Tuple[int, int]]: """The well under ``clamped``, stepping inward over a gap. A pointer pulled back to the edge can land in the spacing between two wells, which is over no well at all; a pointer that was never pulled back is answered exactly as :meth:`well_at` answers it. :param clamped: the pointer after :meth:`_within_view`. :param position: the pointer as it was. """ cell = self.well_at(clamped) if cell is not None or clamped == QPoint(position): return cell dx = (clamped.x() > position.x()) - (clamped.x() < position.x()) dy = (clamped.y() > position.y()) - (clamped.y() < position.y()) for step in range(1, well_side() + 4): cell = self.well_at(clamped + QPoint(dx * step, dy * step)) if cell is not None: return cell return None def _edge_step(self, position) -> Tuple[int, int]: """How far to scroll per tick for a drag at ``position``. A DRAG THAT REACHES THE EDGE SCROLLS THE PLATE. On a 1536-well map in a narrow window the rectangle could otherwise only span the wells already in view. The band is one well wide inside each edge of the viewport; the step grows with depth into the band and keeps growing past the edge, up to :data:`AUTOSCROLL_MAX_STEP`. :param position: the pointer in global coordinates. :returns: ``(dx, dy)`` in pixels; ``(0, 0)`` away from every edge, or when there is nothing to scroll in that direction. """ rect = self._view_rect_global() if rect is None or rect.isEmpty(): return (0, 0) band = max(8, well_side()) def _axis(value, low, high, bar): """The signed scroll step along one axis. :param value: the pointer coordinate on this axis. :param low: the viewport's low edge on this axis. :param high: the viewport's high edge on this axis. :param bar: the scroll bar for this axis. :returns: 0 outside the edge band or when ``bar`` cannot scroll; otherwise a step toward the edge, capped at :data:`AUTOSCROLL_MAX_STEP`. """ if bar.maximum() <= bar.minimum(): return 0 depth = 0 if value < low + band: depth = -(low + band - value) elif value > high - band: depth = value - (high - band) if depth == 0: return 0 step = min(AUTOSCROLL_MAX_STEP, 2 + abs(depth) // 2) return step if depth > 0 else -step return (_axis(position.x(), rect.left(), rect.right(), self._area.horizontalScrollBar()), _axis(position.y(), rect.top(), rect.bottom(), self._area.verticalScrollBar())) def _autoscroll_tick(self) -> None: """Scroll one step toward the held edge and extend the rectangle. The pointer has not moved -- no move event arrives while it is held still -- but the plate has moved under it, so the well at the edge is a new one and the rectangle is redrawn to reach it. """ if self._anchor is None or self._drag_point is None: self._autoscroll.stop() return dx, dy = self._scroll_step horizontal = self._area.horizontalScrollBar() vertical = self._area.verticalScrollBar() before = (horizontal.value(), vertical.value()) horizontal.setValue(horizontal.value() + dx) vertical.setValue(vertical.value() + dy) if (horizontal.value(), vertical.value()) == before: self._autoscroll.stop() return self.drag_to(self._drag_point)
[docs] def select_region(self, start: Tuple[int, int], end: Tuple[int, int], choosing: Optional[bool] = None) -> None: """Select or clear the rectangle between two plate coordinates. Parameters ---------- start, end : tuple of int Inclusive one-based row and column coordinates. choosing : bool, optional ``True`` selects and ``False`` clears. ``None`` inverts the state of the starting well, matching spreadsheet-style drag selection. """ top, bottom = sorted((int(start[0]), int(end[0]))) left, right = sorted((int(start[1]), int(end[1]))) if choosing is None: anchor = self._wells.get((int(start[0]), int(start[1]))) choosing = not (anchor is not None and anchor.isChecked()) for row in range(top, bottom + 1): for column in range(left, right + 1): well = self._wells.get((row, column)) if well is not None and well.isChecked() != choosing: well.setChecked(choosing)
[docs] def selection(self) -> Set[Tuple[int, int]]: """Return selected wells as one-based ``(row, column)`` pairs.""" return {cell for cell, well in self._wells.items() if well.isChecked()}
[docs] def set_selection(self, cells) -> None: """Choose exactly ``cells`` and repaint the whole plate. :param cells: one-based ``(row, column)`` pairs, well-specification text such as ``"A01"`` or ``"c3"``, or a mixture. The field this picker edits is written in the second vocabulary, so it is worth answering to. THE REPAINT IS NOT OPTIONAL. Signals are blocked so that setting a hundred wells says the count once rather than a hundred times, and that also silences the ``toggled -> _paint`` connection -- so a well chosen without a click, which includes the picker's own starting value and everything kept across a layout change, would be checked and still drawn empty. """ wanted: Set[Tuple[int, int]] = set() for cell in cells: if isinstance(cell, str): wanted |= self._read(cell) else: row, column = cell wanted.add((int(row), int(column))) for cell, well in self._wells.items(): well.blockSignals(True) well.setChecked(cell in wanted) well.blockSignals(False) well._paint() self._say()
def _read(self, text) -> Set[Tuple[int, int]]: """Parse a well specification against the current layout. :param text: the specification to parse. :returns: the wells it names, and an empty set when it will not parse -- a field with a typo in it opens the picker empty rather than refusing to open, because the picker is how that typo gets fixed. """ try: return parse(text, self._layout_size) except WellSpecError: return set()
[docs] def value(self) -> str: """Return the selection serialized for a well-specification field.""" return to_text(self.selection(), self._layout_size)
def _say(self, lost: int = 0) -> None: """Restate the layout and how many wells are chosen. :param lost: wells dropped because they are not on this layout, named in the caption so shrinking the plate does not silently discard part of a selection. """ chosen = len(self.selection()) rows, columns = shape(self._layout_size) note = (f"{self._layout_size}-well plate ({rows} x {columns}) — " f"{chosen} well(s) chosen") if lost: note += (f". {lost} well(s) from the previous selection are not " f"on this layout and were dropped") self._caption.setText(note)