"""
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()