Skip to content

Stage Rail

A one-line rail of stages, each with an optional status (to do, working, done, needs attention, blocked). Used by CytoMetrics' stepper and the Gel & Blot workbench.

karcytics_sdk.plugin.stage_rail

A one-line rail of stages: Load › Lanes › Bands › … with each stage's status.

Every stage can be opened at any time (click, or Tab and Space). A stage can carry a status (to do, working, done, needs attention, blocked), shown as a glyph and a colour and spelled out in its tooltip together with any messages, so the status never depends on colour alone. A host that has no statuses to show (a plain stepper) just never sets one.

StageRail

Bases: QWidget

Clickable stages in a row, one current, each with an optional status.

Source code in src/karcytics_sdk/plugin/stage_rail.py
class StageRail(QWidget):
    """Clickable stages in a row, one current, each with an optional status."""

    stage_clicked = Signal(int)

    def __init__(self, labels: list[str], parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self._labels = list(labels)
        self._statuses = [StageStatus.NONE] * len(labels)
        self._messages: list[list[str]] = [[] for _ in labels]
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(2)
        self._group = QButtonGroup(self)
        self._group.setExclusive(True)
        self.buttons: list[QPushButton] = []
        self._arrows: list[QLabel] = []
        for i, label in enumerate(labels):
            if i:
                arrow = QLabel("›")
                arrow.setAlignment(Qt.AlignmentFlag.AlignCenter)
                layout.addWidget(arrow)
                self._arrows.append(arrow)
            button = QPushButton(label)
            button.setCheckable(True)
            button.setCursor(Qt.CursorShape.PointingHandCursor)
            button.setAccessibleName(label)
            self._group.addButton(button, i)
            layout.addWidget(button)
            self.buttons.append(button)
        layout.addStretch()
        self._group.idClicked.connect(self.stage_clicked)
        for i in range(len(labels)):
            self._refresh(i)
        _connect_theme_signal(self.apply_theme)

    def set_current(self, index: int) -> None:
        if 0 <= index < len(self.buttons):
            self.buttons[index].setChecked(True)

    def current(self) -> int:
        return self._group.checkedId()

    def set_status(self, index: int, status: StageStatus, messages: list[str] | None = None) -> None:
        """Show `status` on stage `index`, with `messages` (one per line) in its tooltip."""
        if not 0 <= index < len(self.buttons):
            return
        self._statuses[index] = status
        self._messages[index] = list(messages or [])
        self._refresh(index)
        self.apply_theme()

    def status(self, index: int) -> StageStatus:
        return self._statuses[index]

    def apply_theme(self) -> None:
        colors, _, _ = _get_theme_tokens()
        rules = [
            f"""
            QPushButton {{
                background: transparent; color: {colors.FG_SECONDARY};
                border: 1px solid transparent; border-radius: 10px;
                padding: 3px 8px; font-size: 12px;
            }}
            QPushButton:hover {{ color: {colors.FG_PRIMARY}; border-color: {colors.BORDER}; }}
            QPushButton:focus {{ border-color: {colors.BORDER_FOCUS}; }}
            QPushButton:checked {{
                background: {colors.ACCENT_PRIMARY}; color: {colors.BG_DARKEST}; font-weight: bold;
            }}
            QLabel {{ color: {colors.FG_SECONDARY}; }}
            """
        ]
        for i, button in enumerate(self.buttons):
            status = self._statuses[i]
            if status in (StageStatus.WARNING, StageStatus.ERROR):
                color = getattr(colors, _STATUS_COLOR[status])
                button.setObjectName(f"stage{i}")
                rules.append(f"QPushButton#stage{i}:!checked {{ color: {color}; border-color: {color}; }}")
        self.setStyleSheet("\n".join(rules))

    def _refresh(self, index: int) -> None:
        status = self._statuses[index]
        label = self._labels[index]
        button = self.buttons[index]
        button.setText(f"{status.glyph} {label}" if status.glyph else label)
        lines = [f"Step {index + 1}: {label}"]
        if status.meaning:
            lines[0] += f" ({status.meaning.lower()})"
        lines += [f"• {m}" for m in self._messages[index]]
        button.setToolTip("\n".join(lines))
        button.setAccessibleDescription(status.meaning)

set_status(index, status, messages=None)

Show status on stage index, with messages (one per line) in its tooltip.

Source code in src/karcytics_sdk/plugin/stage_rail.py
def set_status(self, index: int, status: StageStatus, messages: list[str] | None = None) -> None:
    """Show `status` on stage `index`, with `messages` (one per line) in its tooltip."""
    if not 0 <= index < len(self.buttons):
        return
    self._statuses[index] = status
    self._messages[index] = list(messages or [])
    self._refresh(index)
    self.apply_theme()

StageStatus

Bases: Enum

How far a stage has got, and whether it needs the user.

Source code in src/karcytics_sdk/plugin/stage_rail.py
class StageStatus(Enum):
    """How far a stage has got, and whether it needs the user."""

    NONE = ("", "")
    TODO = ("○", "Not done yet")
    BUSY = ("…", "Working")
    OK = ("✓", "Done")
    WARNING = ("!", "Needs attention")
    ERROR = ("✕", "Blocked")

    @property
    def glyph(self) -> str:
        return self.value[0]

    @property
    def meaning(self) -> str:
        return self.value[1]