Source code for ClearMap.gui.pipeline_widgets

from __future__ import annotations
from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
                              QPushButton, QSizePolicy)
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPainterPath, QPalette

from .pipeline_model import LinearPipeline, StepSpec, BINARIZATION_STEPS

_CONNECTOR_H = 18   # shared by _ConnectorArrow and LinearPipelineWidget

# green / red colors compatible with QDarken style, used for the tick/cross marks on the chips.
_TICK_COLOR  = QColor('#5c9e5c')   # muted sage-green
_CROSS_COLOR = QColor('#b85c5c')   # muted dusty-red


[docs] class StepChip(QWidget): """Single step tile — click to toggle enabled/disabled.""" #: Triggered when step is toggled toggled = pyqtSignal(str, bool) # step_id, enabled #: Triggered when ``keep`` step is toggled keep_changed = pyqtSignal(str, bool) # step_id, keep_intermediate _W, _H = 150, 32 _ICON_ZONE = 35 # px reserved on each side for icons def __init__(self, step, spec: StepSpec, parent=None): super().__init__(parent) self._step = step self._spec = spec self.setFixedSize(self._W, self._H) self.setCursor(Qt.PointingHandCursor) self.setToolTip(spec.description)
[docs] def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) pal = self.palette() group = QPalette.Active if self._step.enabled else QPalette.Disabled # ---- colors pulled entirely from the current palette --------------- bg = pal.color(group, QPalette.Button) if self._step.locked: border = pal.color(QPalette.Active, QPalette.Mid) elif self._step.enabled: border = pal.color(QPalette.Active, QPalette.Highlight) else: border = pal.color(QPalette.Disabled, QPalette.Mid) fg = pal.color(group, QPalette.ButtonText) # ---- body ---------------------------------------------------------- p.setBrush(QBrush(bg)) p.setPen(QPen(border, 2)) p.drawRoundedRect(1, 1, self._W - 2, self._H - 2, 7, 7) # label — centred in the middle zone between the two icon zones p.setPen(QPen(fg)) p.setFont(QFont('Segoe UI', 9, QFont.Bold)) p.drawText(self._ICON_ZONE, 0, self._W - 2 * self._ICON_ZONE, self._H, Qt.AlignCenter, self._spec.label) # ---- enabled / disabled tick or cross ------------------------------ if self._step.enabled: mark_color = _TICK_COLOR mark = '✓' else: # Tone the cross down further when the whole step is disabled, # so it doesn't fight with the greyed-out label. mark_color = _CROSS_COLOR.darker(130) if self._step.locked else _CROSS_COLOR mark = '✗' p.setPen(QPen(mark_color)) p.setFont(QFont('Segoe UI', 10, QFont.Bold)) p.drawText(0, 0, self._ICON_ZONE, self._H, Qt.AlignCenter, mark) # ── keep_intermediate indicator — right zone ────────────────────── keep_color = (pal.color(QPalette.Active, QPalette.Highlight) if self._step.keep_intermediate else pal.color(QPalette.Disabled, QPalette.Mid)) p.setPen(QPen(keep_color)) p.setFont(QFont('Segoe UI', 8, QFont.Bold)) action = 'Keep' if self._step.keep_intermediate else 'del' p.drawText(self._W - self._ICON_ZONE, 0, self._ICON_ZONE, self._H, Qt.AlignCenter, action)
[docs] def mousePressEvent(self, event): if event.button() == Qt.LeftButton: x = event.x() if x <= self._ICON_ZONE: # left zone → toggle enable self._step.enabled = not self._step.enabled self.update() self.toggled.emit(self._step.step_id, self._step.enabled) else: # rest of chip → toggle keep_intermediate self._step.keep_intermediate = not self._step.keep_intermediate self.update() self.keep_changed.emit(self._step.step_id, self._step.keep_intermediate)
class _ConnectorArrow(QWidget): """ Self-painting connector placed between two StepChips. Draws a white downward arrow (stem + filled head) centred over the chip column — no coordination with the parent needed. """ def __init__(self, parent=None): super().__init__(parent) self.setFixedHeight(_CONNECTOR_H) def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) cx = StepChip._W // 2 # stay centred over the chip column h = self.height() # == _CONNECTOR_H == 18 color = QColor('white') # ---- stem: thin vertical line ------------------------------------ p.setPen(QPen(color, 2)) p.drawLine(cx, 0, cx, h - 7) # ---- arrowhead: filled downward triangle ------------------------- head = QPainterPath() head.moveTo(cx, h - 1) # tip (bottom) head.lineTo(cx - 5, h - 8) # top-left corner head.lineTo(cx + 5, h - 8) # top-right corner head.closeSubpath() p.setPen(Qt.NoPen) p.setBrush(QBrush(color)) p.drawPath(head)
[docs] class LinearPipelineWidget(QWidget): """ Vertical sequence of StepChips with connecting arrows. Steps can be reordered with ▲/▼ buttons except locked steps (binarize). """ #: Triggered when new pipeline selected pipeline_changed = pyqtSignal(list) # list[str] of enabled step names #: Triggered when ``keep`` checkbox is toggled on a step chip keep_intermediate_changed = pyqtSignal(str, bool) # spec_name, keep CONNECTOR_H = _CONNECTOR_H def __init__(self, pipeline: LinearPipeline, step_registry: dict[str, StepSpec] | None = None, parent=None): super().__init__(parent) self._pipeline = pipeline self._registry = step_registry or BINARIZATION_STEPS self._chips: list[StepChip] = [] self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self._main_layout = QVBoxLayout(self) self._main_layout.setContentsMargins(4, 4, 4, 4) self._main_layout.setSpacing(0) self._populate() # ---- layout management ------------------------------------------------ @staticmethod def _clear_layout(layout: QVBoxLayout) -> None: """Remove and schedule-delete all child widgets from layout.""" while layout.count(): item = layout.takeAt(0) w = item.widget() if w is not None: w.setParent(None) w.deleteLater() def _populate(self) -> None: self._chips.clear() self._clear_layout(self._main_layout) steps = self._pipeline.steps n = len(steps) for i, step in enumerate(steps): spec = self._registry.get(step.spec_name) if spec is None: continue is_last = (i == n - 1) can_up = (not step.locked and i > 0 and not steps[i - 1].locked) can_dn = (not step.locked and not is_last) # ---- row: chip + ▲/▼ column -------------------------------- row = QWidget(self) rl = QHBoxLayout(row) rl.setContentsMargins(0, 0, 0, 0) rl.setSpacing(4) chip = StepChip(step, spec, parent=row) chip.toggled.connect(self._on_toggled) chip.keep_changed.connect(self._on_keep_changed) self._chips.append(chip) rl.addWidget(chip) # Right-side controls (buttons or lock badge) ctrl = QWidget(row) ctrl.setFixedWidth(22) cl = QVBoxLayout(ctrl) cl.setContentsMargins(0, 0, 0, 0) cl.setSpacing(2) if step.locked: lbl = QLabel('🔒', ctrl) lbl.setAlignment(Qt.AlignCenter) lbl.setFixedSize(20, self._H_for_ctrl()) cl.addWidget(lbl) else: for symbol, enabled, direction in (('▲', can_up, -1), ('▼', can_dn, +1)): btn = QPushButton(symbol, ctrl) btn.setFixedSize(20, 20) btn.setEnabled(enabled) btn.setStyleSheet('font-size: 8pt; padding: 0;') btn.clicked.connect( lambda _=False, sid=step.step_id, d=direction: self._on_move(sid, d)) cl.addWidget(btn) rl.addWidget(ctrl) self._main_layout.addWidget(row) # ---- connector arrow (self-painting) --- if not is_last: self._main_layout.addWidget(_ConnectorArrow(self)) self._main_layout.addStretch() def _on_keep_changed(self, _sid: str, _keep: bool) -> None: # Model already updated by StepChip.mousePressEvent. # Emit so the params layer can persist to config. step = next((s for s in self._pipeline.steps if s.step_id == _sid), None) if step is not None: self.keep_intermediate_changed.emit(step.spec_name, _keep) @staticmethod def _H_for_ctrl() -> int: return StepChip._H # ---- slots ------------------------------------------------------------ def _on_toggled(self, _sid: str, _enabled: bool) -> None: # self.update() self.pipeline_changed.emit(self._pipeline.enabled_steps) def _on_move(self, step_id: str, direction: int) -> None: self._pipeline.move(step_id, direction) self._populate() # self.update() self.pipeline_changed.emit(self._pipeline.enabled_steps) # ---- painting --------------------------------------------------------- # def paintEvent(self, event): # super().paintEvent(event) # if len(self._chips) < 2: # return # # p = QPainter(self) # p.setRenderHint(QPainter.Antialiasing) # pal = self.palette() # # for i in range(len(self._chips) - 1): # src = self._chips[i] # dst = self._chips[i + 1] # active = src._step.enabled and dst._step.enabled # # # connector color follows the same palette logic as the chips # color = (pal.color(QPalette.Active, QPalette.Highlight) # if active # else pal.color(QPalette.Disabled, QPalette.Mid)) # # src_pt = src.mapTo(self, QPoint(src.width() // 2, src.height())) # # top-centre of dst chip in self's coordinates # dst_pt = dst.mapTo(self, QPoint(dst.width() // 2, 0)) # # x = src_pt.x() # y0 = src_pt.y() # y1 = dst_pt.y() # # p.setPen(QPen(color, 2)) # p.setBrush(QBrush(color)) # p.drawLine(x, y0, x, y1 - 7) # # # downward arrowhead # arrow = QPainterPath() # arrow.moveTo(x, y1) # arrow.lineTo(x - 4, y1 - 7) # arrow.lineTo(x + 4, y1 - 7) # arrow.closeSubpath() # p.drawPath(arrow) @property def pipeline(self) -> LinearPipeline: return self._pipeline