mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-31 00:31:28 +01:00
feat(sequencer): add blocks concept
This commit is contained in:
3 files changed
+596
-1
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"""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.recipe.recipe_editor import RecipeEditor
|
||||
|
||||
CONCEPTS = {cls.CONCEPT_ID: cls for cls in (RecipeEditor,)}
|
||||
CONCEPTS = {cls.CONCEPT_ID: cls for cls in (RecipeEditor, BlocksEditor)}
|
||||
Whitespace-only changes.
@@ -0,0 +1,594 @@
|
||||
"""Concept 2, Blocks: Scratch-style colored blocks that are edited where they are.
|
||||
|
||||
Drag blocks from the palette into the sequence. Loops are C-shaped blocks that hold other
|
||||
blocks. Every setting is typed straight into the block, so there is no separate form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, QPoint, QRectF, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QDrag, QPainter, QPainterPath, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractSpinBox,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import rgba
|
||||
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 (
|
||||
ScanSettingsDialog,
|
||||
parse_values,
|
||||
values_text,
|
||||
)
|
||||
from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_color, kind_icon, theme
|
||||
|
||||
MIME = "application/x-bec-sequence-block"
|
||||
PALETTE = [
|
||||
("set", "Move a motor", "Go to a position"),
|
||||
("scan_call", "Run a scan", "Any scan from scan control"),
|
||||
("repeat", "Repeat", "Do the inside N times"),
|
||||
("for_each", "For each value", "Once per value in a list"),
|
||||
("while", "While moving", "Repeat inside until a motor arrives"),
|
||||
]
|
||||
|
||||
|
||||
def _mime(payload: dict) -> QMimeData:
|
||||
data = QMimeData()
|
||||
data.setData(MIME, json.dumps(payload).encode())
|
||||
return data
|
||||
|
||||
|
||||
def _payload(mime: QMimeData) -> dict | None:
|
||||
if not mime.hasFormat(MIME):
|
||||
return None
|
||||
return json.loads(bytes(mime.data(MIME)).decode())
|
||||
|
||||
|
||||
def _start_drag(source: QWidget, payload: dict) -> None:
|
||||
drag = QDrag(source)
|
||||
drag.setMimeData(_mime(payload))
|
||||
pixmap = source.grab()
|
||||
drag.setPixmap(pixmap.scaledToWidth(min(pixmap.width(), 360)))
|
||||
drag.setHotSpot(QPoint(12, 12))
|
||||
drag.exec(Qt.DropAction.MoveAction | Qt.DropAction.CopyAction)
|
||||
|
||||
|
||||
class PaletteTile(QFrame):
|
||||
"""A block in the palette: drag it into the sequence or click to append."""
|
||||
|
||||
clicked = Signal(str)
|
||||
|
||||
def __init__(self, kind: str, title: str, text: str, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.kind = kind
|
||||
self._press: QPoint | None = None
|
||||
color = kind_color(kind)
|
||||
self.setCursor(Qt.CursorShape.OpenHandCursor)
|
||||
self.setObjectName("PaletteTile")
|
||||
self.setStyleSheet(
|
||||
f"#PaletteTile {{ background: {rgba(color, 40)}; border: 1px solid {rgba(color, 120)};"
|
||||
f" border-left: 6px solid {color.name()}; border-radius: 8px; }}"
|
||||
)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 8, 10, 8)
|
||||
icon = QLabel(self)
|
||||
icon.setPixmap(kind_icon(kind, 22).pixmap(22, 22))
|
||||
layout.addWidget(icon)
|
||||
texts = QVBoxLayout()
|
||||
texts.setSpacing(0)
|
||||
name = QLabel(title, self)
|
||||
font = name.font()
|
||||
font.setBold(True)
|
||||
name.setFont(font)
|
||||
texts.addWidget(name)
|
||||
texts.addWidget(hint(text, self))
|
||||
layout.addLayout(texts, 1)
|
||||
self.setToolTip("Drag into the sequence, or click to add at the end")
|
||||
|
||||
def mousePressEvent(self, event): # pylint: disable=invalid-name
|
||||
self._press = event.position().toPoint()
|
||||
|
||||
def mouseMoveEvent(self, event): # pylint: disable=invalid-name
|
||||
if (
|
||||
self._press is not None
|
||||
and (event.position().toPoint() - self._press).manhattanLength() > 6
|
||||
):
|
||||
self._press = None
|
||||
_start_drag(self, {"new": self.kind})
|
||||
|
||||
def mouseReleaseEvent(self, event): # pylint: disable=invalid-name
|
||||
if self._press is not None:
|
||||
self.clicked.emit(self.kind)
|
||||
self._press = None
|
||||
|
||||
|
||||
class BlockBody(QWidget):
|
||||
"""Vertical list of blocks that accepts drops between them."""
|
||||
|
||||
def __init__(self, editor: "BlocksEditor", parent_id: str, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.editor = editor
|
||||
self.parent_id = parent_id
|
||||
self.setAcceptDrops(True)
|
||||
self.layout_ = QVBoxLayout(self)
|
||||
self.layout_.setContentsMargins(0, 4, 0, 4)
|
||||
self.layout_.setSpacing(6)
|
||||
self._drop_y: int | None = None
|
||||
self.blocks: list[QWidget] = []
|
||||
|
||||
def add_block(self, block: QWidget) -> None:
|
||||
"""Append a block widget."""
|
||||
self.blocks.append(block)
|
||||
self.layout_.addWidget(block)
|
||||
|
||||
def add_placeholder(self, text: str) -> None:
|
||||
"""Show a dashed drop target when there are no blocks."""
|
||||
label = QLabel(text, self)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setMinimumHeight(40)
|
||||
label.setStyleSheet(
|
||||
f"border: 2px dashed {theme('BORDER', '#c0c0c0').name()}; border-radius: 8px;"
|
||||
f" color: {rgba(theme('FG', '#333333'), 140)};"
|
||||
)
|
||||
self.layout_.addWidget(label)
|
||||
|
||||
def _index_at(self, y: int) -> int:
|
||||
for index, block in enumerate(self.blocks):
|
||||
if y < block.geometry().center().y():
|
||||
return index
|
||||
return len(self.blocks)
|
||||
|
||||
def _line_y(self, index: int) -> int:
|
||||
if not self.blocks:
|
||||
return 4
|
||||
if index < len(self.blocks):
|
||||
return self.blocks[index].geometry().top() - 3
|
||||
return self.blocks[-1].geometry().bottom() + 3
|
||||
|
||||
def dragEnterEvent(self, event): # pylint: disable=invalid-name
|
||||
payload = _payload(event.mimeData())
|
||||
if payload is None or payload.get("move") == self.parent_id:
|
||||
return event.ignore()
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dragMoveEvent(self, event): # pylint: disable=invalid-name
|
||||
payload = _payload(event.mimeData())
|
||||
if payload is None:
|
||||
return event.ignore()
|
||||
moving = payload.get("move")
|
||||
if moving and (
|
||||
moving == self.parent_id
|
||||
or any(
|
||||
a.get("id") == moving
|
||||
for a in model.ancestors(self.editor.document.root, self.parent_id)
|
||||
)
|
||||
):
|
||||
return event.ignore()
|
||||
self._drop_y = self._line_y(self._index_at(int(event.position().y())))
|
||||
self.update()
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dragLeaveEvent(self, _event): # pylint: disable=invalid-name
|
||||
self._drop_y = None
|
||||
self.update()
|
||||
|
||||
def dropEvent(self, event): # pylint: disable=invalid-name
|
||||
payload = _payload(event.mimeData())
|
||||
self._drop_y = None
|
||||
self.update()
|
||||
if payload is None:
|
||||
return event.ignore()
|
||||
index = self._index_at(int(event.position().y()))
|
||||
event.acceptProposedAction()
|
||||
self.editor.drop(payload, self.parent_id if self.parent_id != "root" else None, index)
|
||||
|
||||
def paintEvent(self, event): # pylint: disable=invalid-name
|
||||
super().paintEvent(event)
|
||||
if self._drop_y is None:
|
||||
return
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(QPen(theme("PRIMARY", "#2f6fdf"), 3, cap=Qt.PenCapStyle.RoundCap))
|
||||
painter.drawLine(4, self._drop_y, self.width() - 4, self._drop_y)
|
||||
painter.end()
|
||||
|
||||
|
||||
class Block(QFrame):
|
||||
"""One step drawn as a colored block with its settings inline."""
|
||||
|
||||
def __init__(self, editor: "BlocksEditor", node: dict, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.editor = editor
|
||||
self.node = node
|
||||
self.kind = node["type"]
|
||||
self.color = kind_color(self.kind)
|
||||
self.problem = editor.problem_for(node["id"])
|
||||
self._press: QPoint | None = None
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.setToolTip(self.problem)
|
||||
|
||||
self.outer = QVBoxLayout(self)
|
||||
self.outer.setContentsMargins(0, 0, 0, 0)
|
||||
self.outer.setSpacing(0)
|
||||
self.header = QWidget(self)
|
||||
self.row = QHBoxLayout(self.header)
|
||||
self.row.setContentsMargins(12, 7, 6, 7)
|
||||
self.row.setSpacing(6)
|
||||
grip = QLabel(self.header)
|
||||
grip.setPixmap(material_icon("drag_indicator", size=(18, 18), color=self.color.name()))
|
||||
grip.setCursor(Qt.CursorShape.OpenHandCursor)
|
||||
grip.setToolTip("Drag to move")
|
||||
self.row.addWidget(grip)
|
||||
icon = QLabel(self.header)
|
||||
icon.setPixmap(kind_icon(self.kind, 20).pixmap(20, 20))
|
||||
self.row.addWidget(icon)
|
||||
self.outer.addWidget(self.header)
|
||||
getattr(self, f"_build_{self.kind}", self._build_other)()
|
||||
self.row.addStretch(1)
|
||||
delete = QToolButton(self.header)
|
||||
delete.setAutoRaise(True)
|
||||
delete.setIcon(material_icon("close", size=(16, 16), convert_to_pixmap=False))
|
||||
delete.setToolTip("Remove this block")
|
||||
delete.clicked.connect(lambda: editor.document.delete(node["id"]))
|
||||
self.row.addWidget(delete)
|
||||
|
||||
if model.is_container(node):
|
||||
self.body = BlockBody(editor, node["id"], self)
|
||||
wrapper = QHBoxLayout()
|
||||
wrapper.setContentsMargins(22, 0, 8, 0)
|
||||
wrapper.addWidget(self.body)
|
||||
self.outer.addLayout(wrapper)
|
||||
footer = QWidget(self)
|
||||
footer.setFixedHeight(14)
|
||||
self.outer.addWidget(footer)
|
||||
|
||||
# --- inline editors --------------------------------------------------------------------
|
||||
def _word(self, text: str, bold: bool = False) -> QLabel:
|
||||
label = QLabel(text, self.header)
|
||||
if bold:
|
||||
font = label.font()
|
||||
font.setBold(True)
|
||||
label.setFont(font)
|
||||
self.row.addWidget(label)
|
||||
return label
|
||||
|
||||
def _field(self, widget: QWidget, width: int | None = None) -> QWidget:
|
||||
if width:
|
||||
widget.setFixedWidth(width)
|
||||
self.row.addWidget(widget)
|
||||
return widget
|
||||
|
||||
def _number(self, value, width: int = 80) -> QDoubleSpinBox:
|
||||
spin = QDoubleSpinBox(self.header, decimals=3, minimum=-1e9, maximum=1e9)
|
||||
spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
|
||||
spin.setValue(float(value) if isinstance(value, (int, float)) else 0.0)
|
||||
return self._field(spin, width)
|
||||
|
||||
def _device_combo(self, current: str) -> QComboBox:
|
||||
combo = QComboBox(self.header)
|
||||
combo.setEditable(True)
|
||||
names = self.editor.backend.device_names()
|
||||
combo.addItems(names)
|
||||
combo.setCurrentText(current)
|
||||
return self._field(combo, 120)
|
||||
|
||||
def _build_set(self) -> None:
|
||||
node = self.node
|
||||
bound = bool(node.get("bind_status"))
|
||||
self._word("Start moving" if bound else "Move", True)
|
||||
device = self._device_combo(node.get("device", ""))
|
||||
self._word("to")
|
||||
variables = model.visible_variables(self.editor.document.root, node["id"])
|
||||
source = QComboBox(self.header)
|
||||
source.addItem("value", None)
|
||||
for name in variables:
|
||||
source.addItem(f"each {name}", name)
|
||||
number = self._number(node.get("value"))
|
||||
if model.is_ref(node.get("value")) and source.findData(node["value"]["ref"]) >= 0:
|
||||
source.setCurrentIndex(source.findData(node["value"]["ref"]))
|
||||
if variables:
|
||||
self._field(source)
|
||||
else:
|
||||
source.hide()
|
||||
number.setVisible(source.currentData() is None)
|
||||
wait = QCheckBox("wait", self.header)
|
||||
wait.setChecked(bool(node.get("wait", True)))
|
||||
wait.setVisible(not bound)
|
||||
self.row.addWidget(wait)
|
||||
|
||||
def commit(*_):
|
||||
number.setVisible(source.currentData() is None)
|
||||
value = model.ref(source.currentData()) if source.currentData() else number.value()
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
self.editor.inline_update(
|
||||
node["id"],
|
||||
device=device.currentText().strip() or node.get("device"),
|
||||
value=value,
|
||||
wait=True if bound else wait.isChecked(),
|
||||
)
|
||||
|
||||
device.currentTextChanged.connect(commit)
|
||||
source.currentIndexChanged.connect(commit)
|
||||
number.valueChanged.connect(commit)
|
||||
wait.toggled.connect(commit)
|
||||
|
||||
def _build_scan_call(self) -> None:
|
||||
node = self.node
|
||||
self._word("Run", True)
|
||||
combo = QComboBox(self.header)
|
||||
entries = self.editor.scan_entries()
|
||||
for name in sorted(entries):
|
||||
combo.addItem(model.scan_title(name).lower(), name)
|
||||
if combo.findData(node.get("name")) < 0:
|
||||
combo.addItem(node.get("name", ""), node.get("name"))
|
||||
combo.setCurrentIndex(combo.findData(node.get("name")))
|
||||
self._field(combo)
|
||||
details = QLabel(model.scan_details(node, entries) or "no settings yet", self.header)
|
||||
details.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 170)};")
|
||||
self.row.addWidget(details)
|
||||
settings = QPushButton("Settings…", self.header)
|
||||
settings.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False))
|
||||
self.row.addWidget(settings)
|
||||
|
||||
def change_scan(_index):
|
||||
name = combo.currentData()
|
||||
if name and name != node.get("name"):
|
||||
self.editor.document.update(node["id"], name=name, positional=[], arguments={})
|
||||
|
||||
def open_settings():
|
||||
dialog = ScanSettingsDialog(node, self.editor.client, self)
|
||||
if dialog.exec():
|
||||
name, positional, arguments = dialog.values()
|
||||
self.editor.document.update(
|
||||
node["id"], name=name, positional=positional, arguments=arguments
|
||||
)
|
||||
|
||||
combo.currentIndexChanged.connect(change_scan)
|
||||
settings.clicked.connect(open_settings)
|
||||
|
||||
def _build_repeat(self) -> None:
|
||||
node = self.node
|
||||
self._word("Repeat", True)
|
||||
spin = QSpinBox(self.header, minimum=1, maximum=100000)
|
||||
spin.setValue(int(node.get("count", 1)))
|
||||
self._field(spin, 70)
|
||||
self._word("times")
|
||||
spin.valueChanged.connect(lambda v: self.editor.inline_update(node["id"], count=v))
|
||||
|
||||
def _build_for_each(self) -> None:
|
||||
node = self.node
|
||||
self._word("For each", True)
|
||||
name = QLineEdit(node.get("variable", ""), self.header)
|
||||
self._field(name, 90)
|
||||
self._word("in")
|
||||
values = QLineEdit(values_text(node.get("values") or []), self.header)
|
||||
values.setPlaceholderText("1, 2, 3")
|
||||
self._field(values)
|
||||
values.setMinimumWidth(160)
|
||||
|
||||
def commit(*_):
|
||||
self.editor.inline_update(
|
||||
node["id"],
|
||||
variable=name.text().strip() or node.get("variable"),
|
||||
values=parse_values(values.text()),
|
||||
)
|
||||
|
||||
name.editingFinished.connect(lambda: self.editor.rename_variable(node, name.text()))
|
||||
values.textChanged.connect(commit)
|
||||
|
||||
def _build_while(self) -> None:
|
||||
node = self.node
|
||||
status = model.while_status(node)
|
||||
owner = model.status_owner(self.editor.document.root, status) if status else None
|
||||
self._word("While", True)
|
||||
if owner is None:
|
||||
self._word("the condition holds")
|
||||
return
|
||||
self._word(
|
||||
f"{owner.get('device')} is still moving to {model.format_value(owner.get('value'))}"
|
||||
)
|
||||
|
||||
def _build_other(self) -> None:
|
||||
self._word(model.sentence(self.node, self.editor.document.root), True)
|
||||
|
||||
# --- painting and dragging -------------------------------------------------------------
|
||||
def paintEvent(self, event): # pylint: disable=invalid-name
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 9, 9)
|
||||
fill = QColor(self.color)
|
||||
fill.setAlpha(42 if model.is_container(self.node) else 34)
|
||||
painter.fillPath(path, theme("CARD_BG", "#ffffff"))
|
||||
painter.fillPath(path, fill)
|
||||
border = theme("ACCENT_EMERGENCY", "#e0534b") if self.problem else QColor(self.color)
|
||||
if not self.problem:
|
||||
border.setAlpha(150)
|
||||
painter.setPen(QPen(border, 2 if self.problem else 1))
|
||||
painter.drawPath(path)
|
||||
# solid strip on the left, the "spine" of the block
|
||||
strip = QPainterPath()
|
||||
strip.addRoundedRect(QRectF(rect.left(), rect.top(), 6, rect.height()), 3, 3)
|
||||
painter.fillPath(strip, self.color)
|
||||
painter.end()
|
||||
super().paintEvent(event)
|
||||
|
||||
def mousePressEvent(self, event): # pylint: disable=invalid-name
|
||||
if event.position().y() <= self.header.height():
|
||||
self._press = event.position().toPoint()
|
||||
|
||||
def mouseMoveEvent(self, event): # pylint: disable=invalid-name
|
||||
if (
|
||||
self._press is not None
|
||||
and (event.position().toPoint() - self._press).manhattanLength() > 6
|
||||
):
|
||||
self._press = None
|
||||
_start_drag(self, {"move": self.node["id"]})
|
||||
|
||||
def mouseReleaseEvent(self, _event): # pylint: disable=invalid-name
|
||||
self._press = None
|
||||
|
||||
|
||||
class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
|
||||
"""Scratch-style block editor."""
|
||||
|
||||
CONCEPT_ID = "blocks"
|
||||
CONCEPT_TITLE = "Blocks"
|
||||
CONCEPT_TAGLINE = "Colored blocks you drag together and edit in place, like Scratch."
|
||||
|
||||
def build_editor(self, host: QWidget) -> None:
|
||||
self._inline = False
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(16, 14, 16, 8)
|
||||
layout.setSpacing(16)
|
||||
|
||||
palette = QWidget(host)
|
||||
palette.setFixedWidth(240)
|
||||
pal = QVBoxLayout(palette)
|
||||
pal.setContentsMargins(0, 0, 0, 0)
|
||||
pal.setSpacing(8)
|
||||
pal.addWidget(heading("Blocks", palette))
|
||||
pal.addWidget(
|
||||
hint("Drag a block into the sequence, or click it to add at the end.", palette)
|
||||
)
|
||||
for kind, title, text in PALETTE:
|
||||
tile = PaletteTile(kind, title, text, palette)
|
||||
tile.clicked.connect(self.append)
|
||||
pal.addWidget(tile)
|
||||
pal.addStretch(1)
|
||||
undo = QPushButton("Undo", palette)
|
||||
undo.setIcon(material_icon("undo", size=(16, 16), convert_to_pixmap=False))
|
||||
undo.clicked.connect(self.document.undo)
|
||||
pal.addWidget(undo)
|
||||
layout.addWidget(palette)
|
||||
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(8)
|
||||
right.addWidget(heading("My sequence", host))
|
||||
self.scroll = QScrollArea(host)
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setStyleSheet(
|
||||
f"QScrollArea {{ border: 1px solid {theme('BORDER', '#d0d0d0').name()};"
|
||||
" border-radius: 8px; }"
|
||||
)
|
||||
right.addWidget(self.scroll, 1)
|
||||
layout.addLayout(right, 1)
|
||||
|
||||
@SafeSlot()
|
||||
def refresh(self) -> None:
|
||||
if not hasattr(self, "scroll") or self._inline:
|
||||
return
|
||||
position = self.scroll.verticalScrollBar().value()
|
||||
canvas = QWidget()
|
||||
canvas.setObjectName("BlocksCanvas")
|
||||
canvas.setStyleSheet(f"#BlocksCanvas {{ background: {theme('BG', '#f5f5f7').name()}; }}")
|
||||
layout = QVBoxLayout(canvas)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
start = QLabel("▶ When I press Run", canvas)
|
||||
start.setStyleSheet(
|
||||
f"background: {theme('PRIMARY', '#2f6fdf').name()}; color: white; font-weight: 600;"
|
||||
" padding: 6px 14px; border-radius: 12px;"
|
||||
)
|
||||
start.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||
layout.addWidget(start)
|
||||
body = BlockBody(self, "root", canvas)
|
||||
self._fill(body, self.document.root)
|
||||
layout.addWidget(body)
|
||||
layout.addStretch(1)
|
||||
self.scroll.setWidget(canvas)
|
||||
self.scroll.verticalScrollBar().setValue(position)
|
||||
|
||||
def _fill(self, body: BlockBody, node: dict) -> None:
|
||||
children = model.children_of(node)
|
||||
if not children:
|
||||
body.add_placeholder(
|
||||
"Drag blocks here" if node is self.document.root else "Drop blocks inside the loop"
|
||||
)
|
||||
for child in children:
|
||||
block = Block(self, child, body)
|
||||
body.add_block(block)
|
||||
if model.is_container(child):
|
||||
self._fill(block.body, child)
|
||||
|
||||
def show_problems(self, problems) -> None:
|
||||
ids = {p.node_id for p in problems}
|
||||
if ids != getattr(self, "_problem_ids", set()):
|
||||
self._problem_ids = ids
|
||||
self.refresh()
|
||||
|
||||
# --- actions ---------------------------------------------------------------------------
|
||||
@SafeSlot(str)
|
||||
def append(self, kind: str) -> None:
|
||||
"""Add a block at the end of the sequence."""
|
||||
self.document.add(model.new_step(kind, device=self._default_device()))
|
||||
|
||||
def _default_device(self) -> str:
|
||||
devices = self.backend.device_names()
|
||||
return "samx" if "samx" in devices or not devices else devices[0]
|
||||
|
||||
def drop(self, payload: dict, parent_id: str | None, index: int) -> None:
|
||||
"""Handle a dropped palette tile or block."""
|
||||
if "new" in payload:
|
||||
self.document.add(
|
||||
model.new_step(payload["new"], device=self._default_device()), parent_id, index
|
||||
)
|
||||
return
|
||||
node_id = payload.get("move")
|
||||
node, parent = model.find(self.document.root, node_id)
|
||||
if node is None:
|
||||
return
|
||||
target_parent = parent_id or "root"
|
||||
if parent is not None and parent["id"] == target_parent:
|
||||
if model.children_of(parent).index(node) < index:
|
||||
index -= 1
|
||||
self.document.move(node_id, parent_id, index)
|
||||
|
||||
def inline_update(self, node_id: str, **fields) -> None:
|
||||
"""Store an inline edit without rebuilding the blocks (keeps keyboard focus)."""
|
||||
self._inline = True
|
||||
try:
|
||||
self.document.update(node_id, **fields)
|
||||
finally:
|
||||
self._inline = False
|
||||
|
||||
def rename_variable(self, loop: dict, new_name: str) -> None:
|
||||
"""Rename a for-each variable and every use of it, then redraw."""
|
||||
new_name = new_name.strip()
|
||||
old = loop.get("variable")
|
||||
if not new_name or new_name == old:
|
||||
return
|
||||
|
||||
def visit(value):
|
||||
if model.is_ref(value) and value["ref"] == old:
|
||||
return model.ref(new_name)
|
||||
if isinstance(value, list):
|
||||
return [visit(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: visit(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
for node, _depth, _parent in model.walk(loop):
|
||||
for key in ("value", "positional", "arguments"):
|
||||
if key in node:
|
||||
node[key] = visit(node[key])
|
||||
self.document.update(loop["id"], variable=new_name)
|
||||
Reference in new issue
Block a user