Source code for spacr.qt.regex_editor

"""
Regex editor popup — appears when a dropped folder can't be parsed
by any built-in regex, or when the user clicks "Edit regex" from
the mask app.

Features:

* Editable regex line + "Try" button.
* Live tabular preview of the first 10 filenames parsed by the
  current regex.
* "Auto detect" button (heuristic — see
  :func:`spacr.qt.regex_detect.auto_detect_regex`).
* Dropdown to pick a built-in as a starting point.
* "Save" writes the regex into the target AppScreen's ``custom_regex``
  setting; "Cancel" closes without changes.

The dialog is pure UI — all logic lives in
:mod:`spacr.qt.regex_detect`.
"""
from __future__ import annotations

import logging
from typing import List

from PySide6.QtCore import Qt
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import (
    QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel,
    QLineEdit, QPlainTextEdit, QPushButton, QSizePolicy, QVBoxLayout,
    QWidget,
)

from . import regex_detect as rd


def _let_it_have_its_height(label: QLabel) -> QLabel:
    """Stop a layout squeezing a word-wrapped label below its own text.

    TWO THINGS ARE NEEDED AND EITHER ALONE IS NOT ENOUGH, which is the trap
    this function exists to close:

    * ``(Preferred, Minimum)`` -- the house rule ``prerun._label`` writes
      down. With Qt's default ``Preferred`` height a parent may hand the
      label less than its ``heightForWidth`` and the last lines are
      silently clipped.
    * ``policy.setHeightForWidth(True)`` -- without it a layout never ASKS
      for the height at this width, so the policy above is applied to a
      single-line hint. Setting only the first left this dialog's intro
      still wrapping to 54 px inside 36.

    :param label: a word-wrapped label.
    :returns: the same label, for use inline.
    """
    policy = label.sizePolicy()
    policy.setHorizontalPolicy(QSizePolicy.Preferred)
    policy.setVerticalPolicy(QSizePolicy.Minimum)
    policy.setHeightForWidth(True)
    label.setSizePolicy(policy)
    return label


def _fit_wrapped_height(label: QLabel) -> None:
    """Pin ``label``'s minimum height to the height its text actually needs.

    THE THIRD THING, and the policy above is still not enough without it.
    A layout holding an EXPANDING widget -- here the preview pane -- will
    take height from a `Minimum` neighbour to satisfy it, and
    `heightForWidth` only tells the layout what the label would like. A
    minimum is the only thing it cannot take back.

    Called from ``resizeEvent`` because the answer depends on the width the
    label actually got, which is not known when it is built. Same pattern
    as ``console_panel``'s wrapped label.

    :param label: a word-wrapped label already through
        :func:`_let_it_have_its_height`.
    """
    width = label.width()
    if width <= 0:
        return
    needed = label.heightForWidth(width)
    if needed > 0 and label.minimumHeight() != needed:
        label.setMinimumHeight(needed)

LOG = logging.getLogger("spacr.qt.regex_editor")


[docs] class RegexEditorDialog(QDialog): """Modal editor for the filename metadata regex. :param sample_filenames: bare filenames from the dropped folder used for the live preview (first 20 or so). :param initial_regex: pre-fill the input with this pattern; empty means we run auto-detect on show. :param multichannel: True for regular multi-channel plates; False for single-channel data (relaxes the warning set). :param parent: optional parent widget. :ivar regex: the final chosen regex; only meaningful after :py:meth:`exec` returned ``QDialog.Accepted``. """ def __init__( self, sample_filenames: List[str], initial_regex: str = "", multichannel: bool = True, parent=None, ): """Build the filename-regex editor over a sample of filenames. :param sample_filenames: names to match against; the first twenty are kept, which is enough to see whether a pattern generalises. :param initial_regex: the pattern to open with; empty runs auto-detect. :param multichannel: validate that a channel group is captured. :param parent: parent widget, or ``None``. """ super().__init__(parent) self.setWindowTitle("spaCR — Regex editor") from .preferences import scaled_px self.setMinimumSize(scaled_px(760), scaled_px(520)) self.regex: str = "" self._samples = list(sample_filenames)[:20] self._multi = multichannel outer = QVBoxLayout(self) intro = QLabel( "<b>Filename metadata regex</b><br>" "<span style='color:gray;'>Named groups map onto spaCR's " "downstream fields — most importantly " "<code>plateID</code>, <code>wellID</code>, " "<code>fieldID</code>, and <code>chanID</code>.</span>" ) intro.setTextFormat(Qt.RichText) intro.setWordWrap(True) _let_it_have_its_height(intro) self._intro = intro outer.addWidget(intro) row = QHBoxLayout() self._regex_input = QLineEdit() mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) self._regex_input.setFont(mono) self._regex_input.setPlaceholderText( r"e.g. (?P<plateID>.*)_(?P<wellID>[A-Z]\d{2})_..." ) self._regex_input.textChanged.connect(self._on_regex_changed) row.addWidget(self._regex_input, 1) self._auto_btn = QPushButton("Auto detect") self._auto_btn.clicked.connect(self._on_auto_detect) row.addWidget(self._auto_btn) self._workbench_btn = QPushButton("Work it out from the files…") self._workbench_btn.setToolTip( "Drop the images in, name what each group means, and see the " "new filenames and the folder tree before anything is written.") self._workbench_btn.clicked.connect(self._on_workbench) row.addWidget(self._workbench_btn) wrap = QWidget(); wrap.setLayout(row) outer.addWidget(wrap) preset_row = QHBoxLayout() preset_row.addWidget(QLabel("Or start from a preset:")) self._preset_combo = QComboBox() for label in rd.BUILTIN_REGEXES: self._preset_combo.addItem(label, label) self._preset_combo.addItem("(custom)", None) self._preset_combo.currentIndexChanged.connect(self._on_preset_pick) preset_row.addWidget(self._preset_combo) preset_row.addStretch(1) prewrap = QWidget(); prewrap.setLayout(preset_row) outer.addWidget(prewrap) self._warnings_lbl = QLabel("") self._warnings_lbl.setTextFormat(Qt.RichText) self._warnings_lbl.setWordWrap(True) _let_it_have_its_height(self._warnings_lbl) outer.addWidget(self._warnings_lbl) outer.addWidget(QLabel("Preview:")) self._preview = QPlainTextEdit() self._preview.setReadOnly(True) self._preview.setFont(mono) outer.addWidget(self._preview, 1) buttons = QDialogButtonBox( QDialogButtonBox.Save | QDialogButtonBox.Cancel ) buttons.accepted.connect(self._on_save) buttons.rejected.connect(self.reject) outer.addWidget(buttons) if initial_regex: self._regex_input.setText(initial_regex) else: self._on_auto_detect() def _on_regex_changed(self, text: str) -> None: """Follow the typed pattern with the preset box and the preview. The box is set to the matching preset, or to ``(custom)`` when the text is nobody's preset, with signals blocked so setting it does not read as the user picking one. :param text: the pattern now in the box. """ for i in range(self._preset_combo.count()): key = self._preset_combo.itemData(i) if key is not None and rd.BUILTIN_REGEXES.get(key) == text: self._preset_combo.blockSignals(True) self._preset_combo.setCurrentIndex(i) self._preset_combo.blockSignals(False) break else: self._preset_combo.blockSignals(True) self._preset_combo.setCurrentIndex( self._preset_combo.count() - 1 ) self._preset_combo.blockSignals(False) self._refresh_preview()
[docs] def resizeEvent(self, event): # noqa: N802 (Qt naming) """Re-fit the wrapped labels to the width they have just been given. Both labels grow when the dialog narrows, and a label that is only allowed its one-line hint is the clipping this dialog was found with: the intro wrapped to 54 px inside 36, and the sentence naming `chanID` -- the one thing the dialog exists to explain -- was the part cut off. :param event: the resize event, passed to the base class first; the labels are then re-fitted to the dialog's new width. """ super().resizeEvent(event) for label in (self._intro, self._warnings_lbl): _fit_wrapped_height(label)
def _on_preset_pick(self, _idx: int) -> None: """Put the chosen preset's pattern in the box and re-preview. :param _idx: the newly current index; the preset key is read from the box's data, so it is not used. """ key = self._preset_combo.currentData() if key is None: return pattern = rd.BUILTIN_REGEXES.get(key, "") self._regex_input.blockSignals(True) self._regex_input.setText(pattern) self._regex_input.blockSignals(False) self._refresh_preview() def _on_auto_detect(self) -> None: """Infer a pattern from the sample and say how much of it matched. The preview is rebuilt explicitly rather than left to ``textChanged``: ``QLineEdit`` stays silent when the text is unchanged, so a second click on Auto detect used to stack another status line onto a stale preview, and the no-pattern branch left the warnings blank entirely. """ pattern, label, hits = rd.auto_detect_regex(self._samples) if pattern: self._regex_input.setText(pattern) note = (f"[auto] chose regex `{label}` — matched {hits}/" f"{len(self._samples)} sampled filenames.\n") else: note = "[auto] no regex could be inferred from the sample." self._refresh_preview() self._preview.appendPlainText(note) def _refresh_preview(self) -> None: """Re-run the pattern over the sample and show what it captured. Missing required fields are listed as warnings and unmatched filenames are counted with the first one named -- a pattern that matches most of a folder is the case worth seeing, not just a pass or fail. """ pattern = self._regex_input.text() records, missed = rd.apply_regex(self._samples, pattern) warnings = rd.validate_records(records, multichannel=self._multi) if warnings: self._warnings_lbl.setText( "<b style='color:#d29922;'>⚠ Warnings</b><br>" + "<br>".join(f"• {w}" for w in warnings) ) else: self._warnings_lbl.setText( "<span style='color:#3fb950;'>✓ All required fields " "captured.</span>" ) preview = rd.tabulate_records(records, max_rows=10) if missed: preview += (f"\n\n[{len(missed)} filenames did NOT match — " f"first: {missed[0]}]") self._preview.setPlainText(preview) def _on_workbench(self) -> None: """Open the import workbench on this dialog's files. It hands the pattern BACK rather than saving it, so the editor stays the one place the regex is accepted from -- two doors that both write the setting is how they end up disagreeing about what it is. """ from PySide6.QtWidgets import QDialog from .widgets.import_workbench import ImportWorkbenchDialog dialog = ImportWorkbenchDialog(self._samples, self._regex_input.text().strip(), parent=self) if dialog.exec() == QDialog.Accepted: chosen = dialog.workbench.regex.text().strip() if chosen: self._regex_input.setText(chosen) def _on_save(self) -> None: """Trim the pattern for ``get_regex`` and accept. The box matches whole filenames, so auto-detect's pattern ends with an extension anchor. Saved verbatim into ``custom_regex`` that became ``(...$)..tif`` -- an anchor with characters after it, which can never match -- and it failed silently: 0 of 8 real names, with no error anywhere and the pattern in the box looking exactly right. """ from ..import_plan import for_get_regex self.regex = for_get_regex(self._regex_input.text()) self.accept()