From 47e36d72be887b94d697fb6ac7cf806ea184e5e4 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 23 Oct 2026 23:32:59 +0000 Subject: [PATCH] feat(sequencer): add flowchart concept --- .../views/sequencer_view/concepts.py | 6 +- .../control/sequencer/flow/__init__.py | 0 .../control/sequencer/flow/flow_editor.py | 367 ++++++++++++++++++ 3 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/widgets/control/sequencer/flow/__init__.py create mode 100644 bec_widgets/widgets/control/sequencer/flow/flow_editor.py diff --git a/bec_widgets/applications/views/sequencer_view/concepts.py b/bec_widgets/applications/views/sequencer_view/concepts.py index a6176207..67fd7d33 100644 --- a/bec_widgets/applications/views/sequencer_view/concepts.py +++ b/bec_widgets/applications/views/sequencer_view/concepts.py @@ -1,8 +1,12 @@ """The sequencer GUI concepts, in the order they are shown.""" from bec_widgets.widgets.control.sequencer.blocks.blocks_editor import BlocksEditor +from bec_widgets.widgets.control.sequencer.flow.flow_editor import FlowEditor from bec_widgets.widgets.control.sequencer.guided.guided_editor import GuidedEditor from bec_widgets.widgets.control.sequencer.recipe.recipe_editor import RecipeEditor from bec_widgets.widgets.control.sequencer.table.table_editor import TableEditor -CONCEPTS = {cls.CONCEPT_ID: cls for cls in (RecipeEditor, BlocksEditor, TableEditor, GuidedEditor)} +CONCEPTS = { + cls.CONCEPT_ID: cls + for cls in (RecipeEditor, BlocksEditor, TableEditor, GuidedEditor, FlowEditor) +} diff --git a/bec_widgets/widgets/control/sequencer/flow/__init__.py b/bec_widgets/widgets/control/sequencer/flow/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/control/sequencer/flow/flow_editor.py b/bec_widgets/widgets/control/sequencer/flow/flow_editor.py new file mode 100644 index 00000000..6b88731c --- /dev/null +++ b/bec_widgets/widgets/control/sequencer/flow/flow_editor.py @@ -0,0 +1,367 @@ +"""Concept 5, Flowchart: the sequence as a diagram, plus a timeline of every action. + +Steps are boxes connected by arrows; loops are framed with an arrow going back up. The "+" +on each arrow inserts a step exactly there. Underneath, the timeline unrolls all loops into +the concrete list of moves and scans, so you can check exactly what will run. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QPointF, QRectF, QSize, Qt +from qtpy.QtGui import QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QPolygonF +from qtpy.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsPathItem, + QGraphicsPixmapItem, + QGraphicsScene, + QGraphicsSimpleTextItem, + QGraphicsView, + QHBoxLayout, + QLabel, + QListView, + QListWidget, + QListWidgetItem, + QMenu, + QSplitter, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.sequencer import model +from bec_widgets.widgets.control.sequencer.editor_base import SequenceEditorBase +from bec_widgets.widgets.control.sequencer.inspector import StepInspector +from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_color, kind_icon, theme + +ID_ROLE = Qt.ItemDataRole.UserRole + 1 +ROOT_WIDTH = 560 +BOX_H = 46 +GAP = 38 +PAD = 16 +HEADER_H = 38 +LOOP_MARGIN = 34 + + +def _font(size: int = 10, bold: bool = False) -> QFont: + font = QFont() + font.setPointSize(size) + font.setBold(bold) + return font + + +class StepBox(QGraphicsPathItem): + """A step box; clicking it selects the step.""" + + def __init__(self, editor: "FlowEditor", node: dict, rect: QRectF, text: str): + super().__init__() + self.editor = editor + self.node_id = node["id"] + path = QPainterPath() + path.addRoundedRect(rect, 10, 10) + self.setPath(path) + color = kind_color(node["type"]) + problem = editor.problem_for(node["id"]) + selected = editor.document.selected == node["id"] + fill = QColor(theme("CARD_BG", "#ffffff")) + self.setBrush(QBrush(fill)) + if problem: + pen = QPen(theme("ACCENT_EMERGENCY", "#e0534b"), 2.5) + elif selected: + pen = QPen(theme("PRIMARY", "#2f6fdf"), 2.5) + else: + pen = QPen(theme("BORDER", "#c8c8c8"), 1.2) + self.setPen(pen) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setToolTip(problem or "Click to edit") + strip = QGraphicsPathItem(self) + strip_path = QPainterPath() + strip_path.addRoundedRect(QRectF(rect.left(), rect.top(), 7, rect.height()), 3, 3) + strip.setPath(strip_path) + strip.setBrush(QBrush(color)) + strip.setPen(QPen(Qt.PenStyle.NoPen)) + icon = kind_icon(node["type"], 20).pixmap(20, 20) + pix = QGraphicsPixmapItem(icon, self) + pix.setPos(rect.left() + 18, rect.center().y() - 10) + label = QGraphicsSimpleTextItem(self._elide(text, rect.width() - 70), self) + label.setFont(_font(10)) + label.setBrush(QBrush(theme("FG", "#222222"))) + label.setPos(rect.left() + 48, rect.center().y() - label.boundingRect().height() / 2) + if problem: + badge = QGraphicsSimpleTextItem("needs a fix", self) + badge.setFont(_font(8, True)) + badge.setBrush(QBrush(theme("ACCENT_EMERGENCY", "#e0534b"))) + badge.setPos(rect.right() - badge.boundingRect().width() - 10, rect.top() + 3) + + @staticmethod + def _elide(text: str, width: float) -> str: + max_chars = int(width / 7.2) + return text if len(text) <= max_chars else text[: max_chars - 1] + "…" + + def mousePressEvent(self, event): # pylint: disable=invalid-name + self.editor.document.select(self.node_id) + event.accept() + + def contextMenuEvent(self, event): # pylint: disable=invalid-name + self.editor.step_menu(self.node_id, event.screenPos()) + + +class AddButton(QGraphicsEllipseItem): + """Round "+" on an arrow: inserts a step at this point.""" + + def __init__(self, editor: "FlowEditor", center: QPointF, parent_id: str | None, index: int): + super().__init__(center.x() - 10, center.y() - 10, 20, 20) + self.editor = editor + self.parent_id = parent_id + self.index = index + self.setAcceptHoverEvents(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setToolTip("Add a step here") + self._set_hover(False) + plus = QGraphicsSimpleTextItem("+", self) + plus.setFont(_font(12, True)) + plus.setBrush(QBrush(theme("PRIMARY", "#2f6fdf"))) + box = plus.boundingRect() + plus.setPos(center.x() - box.width() / 2, center.y() - box.height() / 2 - 1) + self.setZValue(5) + + def _set_hover(self, hover: bool) -> None: + primary = theme("PRIMARY", "#2f6fdf") + fill = QColor(primary) + fill.setAlpha(60) + self.setBrush(QBrush(fill if hover else theme("CARD_BG", "#ffffff"))) + self.setPen(QPen(primary, 1.4)) + + def hoverEnterEvent(self, _event): # pylint: disable=invalid-name + self._set_hover(True) + + def hoverLeaveEvent(self, _event): # pylint: disable=invalid-name + self._set_hover(False) + + def mousePressEvent(self, event): # pylint: disable=invalid-name + self.editor.insert_menu(self.parent_id, self.index, event.screenPos()) + event.accept() + + +class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors + """Flowchart editor with a timeline.""" + + CONCEPT_ID = "flow" + CONCEPT_TITLE = "Flowchart" + CONCEPT_TAGLINE = "A diagram with arrows and loop-backs, plus a timeline of every action." + + def build_editor(self, host: QWidget) -> None: + layout = QHBoxLayout(host) + layout.setContentsMargins(0, 0, 0, 0) + splitter = QSplitter(Qt.Orientation.Horizontal, host) + layout.addWidget(splitter) + + left = QWidget(splitter) + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(16, 14, 8, 8) + left_layout.setSpacing(8) + left_layout.addWidget(heading("Flowchart", left)) + left_layout.addWidget( + hint("Click a box to edit it. Click + on an arrow to add a step there.", left) + ) + self.scene = QGraphicsScene(left) + self.view = QGraphicsView(self.scene, left) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) + self.view.setStyleSheet( + f"QGraphicsView {{ border: 1px solid {theme('BORDER', '#d0d0d0').name()};" + f" border-radius: 8px; background: {theme('BG', '#f5f5f7').name()}; }}" + ) + left_layout.addWidget(self.view, 1) + + timeline_head = QHBoxLayout() + timeline_head.addWidget(heading("Timeline: everything that will run, in order", left, 12)) + timeline_head.addStretch(1) + self.timeline_count = QLabel(left) + timeline_head.addWidget(self.timeline_count) + left_layout.addLayout(timeline_head) + self.timeline = QListWidget(left) + self.timeline.setFlow(QListView.Flow.LeftToRight) + self.timeline.setWrapping(False) + self.timeline.setFixedHeight(92) + self.timeline.setIconSize(QSize(18, 18)) + self.timeline.setSpacing(4) + self.timeline.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.timeline.itemClicked.connect( + lambda item: self.document.select(item.data(ID_ROLE) or "") + ) + left_layout.addWidget(self.timeline) + + self.inspector = StepInspector(self.document, splitter, client=self.client) + self.inspector.setMinimumWidth(340) + splitter.addWidget(left) + splitter.addWidget(self.inspector) + splitter.setStretchFactor(0, 3) + splitter.setStretchFactor(1, 2) + self.document.selection_changed.connect(lambda _id: self.refresh()) + + # --- drawing --------------------------------------------------------------------------- + @SafeSlot() + def refresh(self) -> None: + if not hasattr(self, "scene"): + return + self.scene.clear() + self._entries = self.scan_entries() + root = self.document.root + y = self._terminal("Start", 0, theme("PRIMARY", "#2f6fdf")) + y = self._column(root, 0, y, ROOT_WIDTH) + self._terminal("Finish", y, theme("ACCENT_SUCCESS", "#59a869")) + self.scene.setSceneRect(self.scene.itemsBoundingRect().adjusted(-20, -20, 20, 20)) + self._fill_timeline() + + def _terminal(self, text: str, y: float, color: QColor) -> float: + rect = QRectF(ROOT_WIDTH / 2 - 60, y, 120, 32) + path = QPainterPath() + path.addRoundedRect(rect, 16, 16) + item = self.scene.addPath(path, QPen(Qt.PenStyle.NoPen), QBrush(color)) + label = QGraphicsSimpleTextItem(text, item) + label.setFont(_font(10, True)) + label.setBrush(QBrush(QColor("white"))) + box = label.boundingRect() + label.setPos(rect.center().x() - box.width() / 2, rect.center().y() - box.height() / 2) + return y + rect.height() + + def _arrow(self, x: float, y1: float, y2: float) -> None: + pen = QPen(theme("BORDER", "#b0b0b0").darker(115), 1.6) + self.scene.addLine(x, y1, x, y2 - 6, pen) + head = QPolygonF([QPointF(x - 5, y2 - 8), QPointF(x + 5, y2 - 8), QPointF(x, y2)]) + self.scene.addPolygon(head, pen, QBrush(pen.color())) + + def _column(self, container: dict, x: float, y: float, width: float) -> float: + """Lay out a container's children top to bottom; return the bottom y.""" + center = x + width / 2 + parent_id = None if container is self.document.root else container["id"] + children = model.children_of(container) + for index, child in enumerate(children): + self._arrow(center, y, y + GAP) + self.scene.addItem(AddButton(self, QPointF(center, y + GAP / 2), parent_id, index)) + y += GAP + y = self._step(child, x, y, width) + self._arrow(center, y, y + GAP) + self.scene.addItem(AddButton(self, QPointF(center, y + GAP / 2), parent_id, len(children))) + return y + GAP + + def _step(self, node: dict, x: float, y: float, width: float) -> float: + text = model.sentence(node, self.document.root, self._entries) + if not model.is_container(node): + self.scene.addItem(StepBox(self, node, QRectF(x, y, width, BOX_H), text)) + return y + BOX_H + color = kind_color(node["type"]) + top = y + header = StepBox(self, node, QRectF(x, y, width, HEADER_H), text) + self.scene.addItem(header) + inner_x = x + PAD + inner_w = width - PAD - LOOP_MARGIN + bottom = self._column(node, inner_x, y + HEADER_H, inner_w) + 4 + frame = QPainterPath() + frame.addRoundedRect(QRectF(x, top, width, bottom - top), 12, 12) + pen = QPen(color, 1.4, Qt.PenStyle.DashLine) + fill = QColor(color) + fill.setAlpha(22) + item = self.scene.addPath(frame, pen, QBrush(fill)) + item.setZValue(-10) + # loop-back arrow on the right + loop_x = x + width - LOOP_MARGIN / 2 + back = QPainterPath(QPointF(x + inner_w + PAD, bottom - 18)) + back.lineTo(loop_x - 6, bottom - 18) + back.quadTo(loop_x, bottom - 18, loop_x, bottom - 24) + back.lineTo(loop_x, top + HEADER_H + 10) + self.scene.addPath(back, QPen(color, 1.8)) + head = QPolygonF( + [ + QPointF(loop_x - 5, top + HEADER_H + 16), + QPointF(loop_x + 5, top + HEADER_H + 16), + QPointF(loop_x, top + HEADER_H + 7), + ] + ) + self.scene.addPolygon(head, QPen(color), QBrush(color)) + label = QGraphicsSimpleTextItem(self._loop_label(node)) + label.setFont(_font(8, True)) + label.setBrush(QBrush(color)) + label.setRotation(-90) + label.setPos(loop_x - 16, (top + HEADER_H + bottom) / 2 + label.boundingRect().width() / 2) + self.scene.addItem(label) + return bottom + + @staticmethod + def _loop_label(node: dict) -> str: + if node["type"] == "repeat": + return f"again, {node.get('count', 1)}× in total" + if node["type"] == "for_each": + return f"next {node.get('variable')}" + if node["type"] == "while": + return "again until it arrives" + return "" + + def _fill_timeline(self) -> None: + self.timeline.clear() + items = model.timeline(self.document.root, self._entries) + for index, item in enumerate(items, 1): + short = item.text + if item.kind == "set": + short = short.replace("Move ", "").replace(" to ", " → ") + elif item.kind == "scan_call": + short = short.split(":")[0].replace("Run ", "") + entry = QListWidgetItem(kind_icon(item.kind, 18), f"{index}. {short}\n{item.context}") + entry.setData(ID_ROLE, item.node_id) + entry.setToolTip(f"{item.text}\n{item.context}") + color = kind_color(item.kind) + entry.setBackground(QBrush(QColor(color.red(), color.green(), color.blue(), 34))) + entry.setSizeHint(QSize(200, 60)) + self.timeline.addItem(entry) + self.timeline_count.setText(model.summarize(self.document.root).text()) + + def show_problems(self, problems) -> None: + self.refresh() + + # --- menus ----------------------------------------------------------------------------- + def insert_menu(self, parent_id: str | None, index: int, screen_pos) -> None: + """Ask which step to insert at a "+".""" + menu = QMenu(self) + for kind in ("set", "scan_call", "repeat", "for_each", "while"): + action = menu.addAction(kind_icon(kind, 16), model.KIND_TITLES[kind]) + action.setData(kind) + chosen = menu.exec(screen_pos) + if chosen is not None: + devices = self.backend.device_names() + device = "samx" if "samx" in devices or not devices else devices[0] + self.document.add(model.new_step(chosen.data(), device=device), parent_id, index) + + def step_menu(self, node_id: str, screen_pos) -> None: + """Right-click menu of a step.""" + doc = self.document + menu = QMenu(self) + actions = { + menu.addAction( + material_icon("arrow_upward", size=(16, 16), convert_to_pixmap=False), "Move up" + ): lambda: doc.shift(node_id, -1), + menu.addAction( + material_icon("arrow_downward", size=(16, 16), convert_to_pixmap=False), "Move down" + ): lambda: doc.shift(node_id, 1), + menu.addAction( + material_icon("content_copy", size=(16, 16), convert_to_pixmap=False), "Duplicate" + ): lambda: doc.duplicate(node_id), + menu.addAction(kind_icon("repeat", 16), "Put inside Repeat"): lambda: doc.wrap( + node_id, "repeat" + ), + menu.addAction(kind_icon("for_each", 16), "Put inside For each"): lambda: doc.wrap( + node_id, "for_each" + ), + menu.addAction( + material_icon("delete", size=(16, 16), convert_to_pixmap=False), "Delete" + ): lambda: doc.delete(node_id), + } + chosen = menu.exec(screen_pos) + if chosen in actions: + actions[chosen]() + + def screenshot_setup(self) -> None: + """Select the move step for screenshots.""" + moves = [n for n, _d, _p in model.walk(self.document.root) if n["type"] == "set"] + if moves: + self.document.select(moves[0]["id"])