Reorganized the application around a clear frontend/backend boundary.
src/bedit/
├── __main__.py
├── core/ # Pure Python, no PySide
│ ├── model.py
│ ├── port_types.py
│ ├── serializer.py
│ └── libraries.py
└── gui/ # All Qt-dependent code
├── app.py
├── main_window.py
├── preferences.py
├── controllers/
├── dialogs/
├── graphics/
├── models/
└── generated/ # Designer/resource output only
Notable improvements:
Domain models, serialization, port types, and library parsing are now Qt-free.
Qt signals and undo infrastructure are explicitly isolated under gui/controllers.
Library parsing is separated from the Qt repository and tree models.
All generated Python is contained in gui/generated.
Designer build tasks now write to the generated directory.
The application entry point and package metadata use the new paths.
README now documents the structure and dependency rules.
Removed the old mixed document, library, and workspace packages.
This commit is contained in:
5
BEdit/src/bedit/gui/graphics/__init__.py
Normal file
5
BEdit/src/bedit/gui/graphics/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Graphics scenes, views, editors, and renderers."""
|
||||
|
||||
from bedit.gui.graphics.workspace import GraphWorkspaceView
|
||||
|
||||
__all__ = ["GraphWorkspaceView"]
|
||||
381
BEdit/src/bedit/gui/graphics/icon_editor.py
Normal file
381
BEdit/src/bedit/gui/graphics/icon_editor.py
Normal file
@@ -0,0 +1,381 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPen, QPolygonF
|
||||
from PySide6.QtWidgets import (
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGraphicsEllipseItem,
|
||||
QGraphicsItem,
|
||||
QGraphicsObject,
|
||||
QGraphicsScene,
|
||||
QGraphicsSceneContextMenuEvent,
|
||||
QGraphicsSceneMouseEvent,
|
||||
QGraphicsView,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bedit.core.model import Component, Icon, Port
|
||||
from bedit.gui.graphics.icon_renderer import _pen
|
||||
from bedit.gui.preferences import application_settings
|
||||
|
||||
|
||||
def _icon_grid_size() -> int:
|
||||
return application_settings().value("grid/iconSize", 8, type=int)
|
||||
|
||||
|
||||
def _snap(value: float) -> float:
|
||||
grid = _icon_grid_size()
|
||||
return round(value / grid) * grid
|
||||
|
||||
|
||||
class IconEditorView(QGraphicsView):
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
||||
painter.fillRect(rect, QColor("#ffffff"))
|
||||
grid = _icon_grid_size()
|
||||
painter.setPen(QPen(QColor("#dbeafe"), 0))
|
||||
left = int(rect.left()) - int(rect.left()) % grid
|
||||
top = int(rect.top()) - int(rect.top()) % grid
|
||||
for x in range(left, int(rect.right()) + grid, grid):
|
||||
painter.drawLine(x, rect.top(), x, rect.bottom())
|
||||
for y in range(top, int(rect.bottom()) + grid, grid):
|
||||
painter.drawLine(rect.left(), y, rect.right(), y)
|
||||
|
||||
|
||||
class ResizeHandle(QGraphicsEllipseItem):
|
||||
def __init__(self, owner: "ShapeItem") -> None:
|
||||
super().__init__(-4, -4, 8, 8, owner)
|
||||
self.owner = owner
|
||||
self.setBrush(QColor("#ffffff"))
|
||||
self.setPen(QPen(QColor("#2563eb"), 1.5))
|
||||
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
self.setZValue(20)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
maximum = (
|
||||
self.owner.scene().sceneRect().bottomRight() - self.owner.pos()
|
||||
if self.owner.scene()
|
||||
else QPointF(128, 128)
|
||||
)
|
||||
value = QPointF(
|
||||
min(maximum.x(), max(_icon_grid_size(), _snap(value.x()))),
|
||||
min(maximum.y(), max(_icon_grid_size(), _snap(value.y()))),
|
||||
)
|
||||
if self.owner.element.get("type") == "circle":
|
||||
side = min(maximum.x(), maximum.y(), max(value.x(), value.y()))
|
||||
value = QPointF(side, side)
|
||||
self.owner.prepareGeometryChange()
|
||||
self.owner.element["width"] = value.x()
|
||||
self.owner.element["height"] = value.y()
|
||||
self.owner.update()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
def __init__(self, color: str, allow_none: bool = False, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.color = color
|
||||
self.allow_none = allow_none
|
||||
self.clicked.connect(self.choose)
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self) -> None:
|
||||
self.setText("No fill" if self.color == "none" else self.color)
|
||||
swatch = "transparent" if self.color == "none" else self.color
|
||||
self.setStyleSheet(f"QPushButton {{ background: {swatch}; }}")
|
||||
|
||||
def choose(self) -> None:
|
||||
initial = QColor("#ffffff" if self.color == "none" else self.color)
|
||||
color = QColorDialog.getColor(initial, self, "Choose colour", QColorDialog.ColorDialogOption.ShowAlphaChannel)
|
||||
if color.isValid():
|
||||
self.color = color.name(QColor.NameFormat.HexArgb) if color.alpha() < 255 else color.name()
|
||||
self._refresh()
|
||||
|
||||
|
||||
class ShapeOptionsDialog(QDialog):
|
||||
def __init__(self, element: dict, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.element = deepcopy(element)
|
||||
self.setWindowTitle("Shape Options")
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
self.line_style = QComboBox()
|
||||
self.line_style.addItems(["solid", "dash", "dot", "dash-dot", "none"])
|
||||
self.line_style.setCurrentText(self.element.get("lineStyle", "solid"))
|
||||
self.line_width = QDoubleSpinBox()
|
||||
self.line_width.setRange(0.1, 20.0)
|
||||
self.line_width.setValue(float(self.element.get("lineWidth", 1.5)))
|
||||
self.stroke = ColorButton(self.element.get("stroke", "#303030"))
|
||||
self.fill_type = QComboBox()
|
||||
self.fill_type.addItems(["solid", "none"])
|
||||
fill = self.element.get("fill", "#ffffff")
|
||||
self.fill_type.setCurrentText("none" if fill in {"none", "transparent", ""} else "solid")
|
||||
self.fill = ColorButton("#ffffff" if fill in {"none", "transparent", ""} else fill)
|
||||
self.width = QDoubleSpinBox()
|
||||
self.width.setRange(1, 500)
|
||||
self.width.setValue(float(self.element.get("width", 20)))
|
||||
self.height = QDoubleSpinBox()
|
||||
self.height.setRange(1, 500)
|
||||
self.height.setValue(float(self.element.get("height", 20)))
|
||||
form.addRow("Width:", self.width)
|
||||
form.addRow("Height:", self.height)
|
||||
form.addRow("Line style:", self.line_style)
|
||||
form.addRow("Line width:", self.line_width)
|
||||
form.addRow("Line colour:", self.stroke)
|
||||
if self.element.get("type") != "line":
|
||||
form.addRow("Fill type:", self.fill_type)
|
||||
form.addRow("Fill colour:", self.fill)
|
||||
self.radius = None
|
||||
if self.element.get("type") == "rectangle":
|
||||
self.radius = QDoubleSpinBox()
|
||||
self.radius.setRange(0, 50)
|
||||
self.radius.setValue(float(self.element.get("cornerRadius", 0)))
|
||||
form.addRow("Corner radius:", self.radius)
|
||||
self.text_edit = None
|
||||
self.font_size = None
|
||||
if self.element.get("type") == "text":
|
||||
self.text_edit = QLineEdit(str(self.element.get("text", "Text")))
|
||||
self.font_size = QDoubleSpinBox()
|
||||
self.font_size.setRange(4, 96)
|
||||
self.font_size.setValue(float(self.element.get("fontSize", 12)))
|
||||
form.addRow("Text:", self.text_edit)
|
||||
form.addRow("Font size:", self.font_size)
|
||||
layout.addLayout(form)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def accept(self) -> None:
|
||||
self.element["lineStyle"] = self.line_style.currentText()
|
||||
self.element["lineWidth"] = self.line_width.value()
|
||||
self.element["stroke"] = self.stroke.color
|
||||
self.element["width"] = self.width.value()
|
||||
self.element["height"] = self.height.value()
|
||||
if self.element.get("type") != "line":
|
||||
self.element["fill"] = self.fill.color if self.fill_type.currentText() == "solid" else "none"
|
||||
if self.radius is not None:
|
||||
self.element["cornerRadius"] = self.radius.value()
|
||||
if self.text_edit is not None:
|
||||
self.element["text"] = self.text_edit.text()
|
||||
self.element["fontSize"] = self.font_size.value()
|
||||
self.element["color"] = self.stroke.color
|
||||
super().accept()
|
||||
|
||||
|
||||
class ShapeItem(QGraphicsObject):
|
||||
def __init__(self, element: dict) -> None:
|
||||
super().__init__()
|
||||
self.element = element
|
||||
self.setPos(float(element.get("x", 0)), float(element.get("y", 0)))
|
||||
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
self.resize_handle = ResizeHandle(self)
|
||||
self.resize_handle.setPos(float(element.get("width", 20)), float(element.get("height", 20)))
|
||||
self.resize_handle.hide()
|
||||
|
||||
def boundingRect(self) -> QRectF: # noqa: N802
|
||||
margin = max(3.0, float(self.element.get("lineWidth", 1.5)))
|
||||
return QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20))).adjusted(-margin, -margin, margin, margin)
|
||||
|
||||
def paint(self, painter: QPainter, option, widget=None) -> None:
|
||||
del option, widget
|
||||
rect = QRectF(0, 0, float(self.element.get("width", 20)), float(self.element.get("height", 20)))
|
||||
painter.setPen(_pen(self.element))
|
||||
fill = self.element.get("fill", "none")
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
|
||||
kind = self.element.get("type")
|
||||
if kind == "rectangle":
|
||||
radius = float(self.element.get("cornerRadius", 0))
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
elif kind in {"circle", "ellipse"}:
|
||||
painter.drawEllipse(rect)
|
||||
elif kind == "line":
|
||||
painter.drawLine(rect.topLeft(), rect.bottomRight())
|
||||
elif kind == "triangle":
|
||||
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), 0), rect.bottomRight(), rect.bottomLeft()]))
|
||||
elif kind == "text":
|
||||
painter.setPen(QColor(self.element.get("color", "#202020")))
|
||||
font = painter.font()
|
||||
font.setPointSizeF(float(self.element.get("fontSize", 12)))
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(self.element.get("text", "Text")))
|
||||
if self.isSelected():
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#2563eb"), 1, Qt.PenStyle.DashLine))
|
||||
painter.drawRect(rect)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
|
||||
value = QPointF(
|
||||
max(
|
||||
bounds.left(),
|
||||
min(
|
||||
bounds.right() - float(self.element.get("width", 20)),
|
||||
_snap(value.x()),
|
||||
),
|
||||
),
|
||||
max(
|
||||
bounds.top(),
|
||||
min(
|
||||
bounds.bottom() - float(self.element.get("height", 20)),
|
||||
_snap(value.y()),
|
||||
),
|
||||
),
|
||||
)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
self.element["x"], self.element["y"] = value.x(), value.y()
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
||||
self.resize_handle.setVisible(bool(value))
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
super().mouseReleaseEvent(event)
|
||||
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
|
||||
self.setPos(
|
||||
max(bounds.left(), min(bounds.right() - float(self.element.get("width", 20)), _snap(self.pos().x()))),
|
||||
max(bounds.top(), min(bounds.bottom() - float(self.element.get("height", 20)), _snap(self.pos().y()))),
|
||||
)
|
||||
|
||||
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
||||
menu = QMenu()
|
||||
options = menu.addAction("Shape Options…")
|
||||
delete = menu.addAction("Delete Shape")
|
||||
chosen = menu.exec(event.screenPos())
|
||||
if chosen is options:
|
||||
dialog = ShapeOptionsDialog(self.element)
|
||||
if dialog.exec() == dialog.DialogCode.Accepted:
|
||||
self.prepareGeometryChange()
|
||||
self.element.clear()
|
||||
self.element.update(dialog.element)
|
||||
self.resize_handle.setPos(
|
||||
float(self.element.get("width", 20)),
|
||||
float(self.element.get("height", 20)),
|
||||
)
|
||||
self.update()
|
||||
elif chosen is delete and self.scene() is not None:
|
||||
self.scene().removeItem(self)
|
||||
self.element["_deleted"] = True
|
||||
event.accept()
|
||||
|
||||
|
||||
class PortHandle(QGraphicsEllipseItem):
|
||||
def __init__(self, port: Port, direction: str, position: QPointF) -> None:
|
||||
super().__init__(-5, -5, 10, 10)
|
||||
self.port, self.direction = port, direction
|
||||
self.setPos(position)
|
||||
self.setBrush(QColor("#16a34a" if direction == "input" else "#dc2626"))
|
||||
self.setPen(QPen(QColor("#ffffff"), 1.5))
|
||||
self.setToolTip(f"{direction.title()}: {port.name} (drag to position)")
|
||||
self.setZValue(10)
|
||||
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
|
||||
value = QPointF(
|
||||
min(bounds.right(), max(bounds.left(), _snap(value.x()))),
|
||||
min(bounds.bottom(), max(bounds.top(), _snap(value.y()))),
|
||||
)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
self.port.properties["iconPosition"] = {"x": value.x(), "y": value.y()}
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
super().mouseReleaseEvent(event)
|
||||
bounds = self.scene().sceneRect() if self.scene() else QRectF(0, 0, 128, 128)
|
||||
self.setPos(
|
||||
min(bounds.right(), max(bounds.left(), _snap(self.pos().x()))),
|
||||
min(bounds.bottom(), max(bounds.top(), _snap(self.pos().y()))),
|
||||
)
|
||||
|
||||
|
||||
class IconEditorDialog(QDialog):
|
||||
def __init__(self, component: Component, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Icon Editor — {component.name}")
|
||||
self.resize(850, 600)
|
||||
self.icon = Icon.from_dict(component.icon.to_dict())
|
||||
self.inputs = deepcopy(component.inputs)
|
||||
self.outputs = deepcopy(component.outputs)
|
||||
layout = QVBoxLayout(self)
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.addWidget(QLabel("Add:"))
|
||||
for kind in ("rectangle", "circle", "ellipse", "line", "triangle", "text"):
|
||||
button = QToolButton()
|
||||
button.setText(kind.title())
|
||||
button.clicked.connect(lambda _checked=False, value=kind: self.add_shape(value))
|
||||
toolbar.addWidget(button)
|
||||
toolbar.addStretch()
|
||||
delete = QPushButton("Delete selected")
|
||||
delete.clicked.connect(self.delete_selected)
|
||||
toolbar.addWidget(delete)
|
||||
layout.addLayout(toolbar)
|
||||
self.scene = QGraphicsScene(0, 0, self.icon.width, self.icon.height, self)
|
||||
self.view = IconEditorView(self.scene)
|
||||
self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self.view.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
|
||||
layout.addWidget(self.view, 1)
|
||||
layout.addWidget(QLabel("Green points are inputs; red points are outputs. Drag them to place connection anchors."))
|
||||
for element in self.icon.elements:
|
||||
self.scene.addItem(ShapeItem(element))
|
||||
self._add_ports(self.inputs, "input", 0.0)
|
||||
self._add_ports(self.outputs, "output", self.icon.width)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
self.view.fitInView(self.scene.sceneRect().adjusted(-10, -10, 10, 10), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
|
||||
spacing = self.icon.height / (len(ports) + 1)
|
||||
for index, port in enumerate(ports, 1):
|
||||
saved = port.properties.get("iconPosition", {})
|
||||
position = QPointF(float(saved.get("x", default_x)), float(saved.get("y", spacing * index)))
|
||||
self.scene.addItem(PortHandle(port, direction, position))
|
||||
|
||||
def add_shape(self, kind: str) -> None:
|
||||
count = len([item for item in self.scene.items() if isinstance(item, ShapeItem)])
|
||||
x, y = 15 + (count * 5) % 40, 15 + (count * 4) % 25
|
||||
element = {"type": kind, "x": x, "y": y, "width": 55, "height": 35, "fill": "#dbeafe", "stroke": "#303030", "lineWidth": 1.5, "lineStyle": "solid"}
|
||||
if kind == "circle":
|
||||
element["width"] = element["height"] = 35
|
||||
if kind == "line":
|
||||
element["fill"] = "none"
|
||||
if kind == "rectangle":
|
||||
element["cornerRadius"] = 0
|
||||
if kind == "text":
|
||||
text, accepted = QInputDialog.getText(self, "Add Text", "Text:", text="Text")
|
||||
if not accepted:
|
||||
return
|
||||
element.update({"text": text, "fontSize": 12, "color": "#202020", "fill": "none", "lineStyle": "none"})
|
||||
self.icon.elements.append(element)
|
||||
item = ShapeItem(element)
|
||||
self.scene.addItem(item)
|
||||
item.setSelected(True)
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
for item in self.scene.selectedItems():
|
||||
if isinstance(item, ShapeItem):
|
||||
self.scene.removeItem(item)
|
||||
item.element["_deleted"] = True
|
||||
|
||||
def accept(self) -> None:
|
||||
self.icon.elements = [element for element in self.icon.elements if not element.pop("_deleted", False)]
|
||||
super().accept()
|
||||
98
BEdit/src/bedit/gui/graphics/icon_renderer.py
Normal file
98
BEdit/src/bedit/gui/graphics/icon_renderer.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap, QPolygonF
|
||||
|
||||
from bedit.core.model import Icon
|
||||
|
||||
|
||||
def icon_bounds(icon: Icon) -> QRectF:
|
||||
"""Return the automatic hitbox of all visible vector elements."""
|
||||
bounds = QRectF()
|
||||
for element in icon.elements:
|
||||
if element.get("_deleted"):
|
||||
continue
|
||||
rect = QRectF(
|
||||
float(element.get("x", 0)),
|
||||
float(element.get("y", 0)),
|
||||
max(0.0, float(element.get("width", 0))),
|
||||
max(0.0, float(element.get("height", 0))),
|
||||
)
|
||||
bounds = rect if bounds.isNull() else bounds.united(rect)
|
||||
return bounds if not bounds.isNull() else QRectF(32, 32, 64, 64)
|
||||
|
||||
|
||||
def _pen(element: dict) -> QPen:
|
||||
styles = {
|
||||
"solid": Qt.PenStyle.SolidLine,
|
||||
"dash": Qt.PenStyle.DashLine,
|
||||
"dot": Qt.PenStyle.DotLine,
|
||||
"dash-dot": Qt.PenStyle.DashDotLine,
|
||||
"none": Qt.PenStyle.NoPen,
|
||||
}
|
||||
return QPen(
|
||||
QColor(element.get("stroke", "#303030")),
|
||||
float(element.get("lineWidth", 1.5)),
|
||||
styles.get(element.get("lineStyle", "solid"), Qt.PenStyle.SolidLine),
|
||||
)
|
||||
|
||||
|
||||
def paint_icon(
|
||||
painter: QPainter,
|
||||
icon: Icon,
|
||||
target: QRectF,
|
||||
source: QRectF | None = None,
|
||||
) -> None:
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
if source is None:
|
||||
source = QRectF(0, 0, icon.width, icon.height)
|
||||
painter.translate(target.topLeft())
|
||||
painter.scale(target.width() / source.width(), target.height() / source.height())
|
||||
painter.translate(-source.left(), -source.top())
|
||||
for element in icon.elements:
|
||||
kind = element.get("type", "rectangle")
|
||||
rect = QRectF(
|
||||
float(element.get("x", 0)), float(element.get("y", 0)),
|
||||
float(element.get("width", 20)), float(element.get("height", 20)),
|
||||
)
|
||||
painter.setPen(_pen(element))
|
||||
fill = element.get("fill", "#ffffff")
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush if fill in {"none", "transparent", ""} else QColor(fill))
|
||||
if kind == "rectangle":
|
||||
radius = float(element.get("cornerRadius", 0))
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
elif kind in {"circle", "ellipse"}:
|
||||
painter.drawEllipse(rect)
|
||||
elif kind == "line":
|
||||
painter.drawLine(rect.topLeft(), rect.bottomRight())
|
||||
elif kind == "triangle":
|
||||
painter.drawPolygon(QPolygonF([QPointF(rect.center().x(), rect.top()), rect.bottomRight(), rect.bottomLeft()]))
|
||||
elif kind == "text":
|
||||
painter.setPen(QColor(element.get("color", element.get("stroke", "#202020"))))
|
||||
font = QFont()
|
||||
font.setPointSizeF(float(element.get("fontSize", 12)))
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, str(element.get("text", "Text")))
|
||||
painter.restore()
|
||||
|
||||
|
||||
def icon_pixmap(icon: Icon, size: int = 16) -> QPixmap:
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
bounds = icon_bounds(icon)
|
||||
ratio = min(size / bounds.width(), size / bounds.height())
|
||||
width, height = bounds.width() * ratio, bounds.height() * ratio
|
||||
paint_icon(
|
||||
painter,
|
||||
icon,
|
||||
QRectF((size - width) / 2, (size - height) / 2, width, height),
|
||||
bounds,
|
||||
)
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
def library_icon(icon: Icon) -> QIcon:
|
||||
# Render large enough for the tall Libraries rows. icon_pixmap crops to the
|
||||
# vector hitbox first, so a 32x32 drawing is shown as large as a 128x128 one.
|
||||
return QIcon(icon_pixmap(icon, 28))
|
||||
616
BEdit/src/bedit/gui/graphics/workspace.py
Normal file
616
BEdit/src/bedit/gui/graphics/workspace.py
Normal file
@@ -0,0 +1,616 @@
|
||||
import json
|
||||
|
||||
from PySide6.QtCore import QMimeData, QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QDragEnterEvent,
|
||||
QDropEvent,
|
||||
QMouseEvent,
|
||||
QWheelEvent,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QTransform,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QGraphicsEllipseItem,
|
||||
QGraphicsItem,
|
||||
QGraphicsObject,
|
||||
QGraphicsPathItem,
|
||||
QGraphicsScene,
|
||||
QGraphicsSceneContextMenuEvent,
|
||||
QGraphicsSceneMouseEvent,
|
||||
QGraphicsView,
|
||||
QApplication,
|
||||
QMenu,
|
||||
QStyleOptionGraphicsItem,
|
||||
QToolTip,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bedit.core.model import Component, Connection, Endpoint, Port
|
||||
from bedit.gui.controllers.document import DocumentController
|
||||
from bedit.gui.models.library_tree import COMPONENT_MIME_TYPE
|
||||
from bedit.gui.graphics.icon_renderer import icon_bounds, paint_icon
|
||||
from bedit.gui.preferences import application_settings
|
||||
|
||||
|
||||
SELECTION_MIME_TYPE = "application/x-bedit-selection"
|
||||
|
||||
|
||||
def _graph_snap_size() -> int:
|
||||
return application_settings().value("grid/graphSnapSize", 8, type=int)
|
||||
|
||||
|
||||
def _graph_grid_size() -> int:
|
||||
return application_settings().value("grid/graphSize", 64, type=int)
|
||||
|
||||
|
||||
def _snapped(position: QPointF) -> QPointF:
|
||||
grid = _graph_snap_size()
|
||||
return QPointF(round(position.x() / grid) * grid, round(position.y() / grid) * grid)
|
||||
|
||||
|
||||
class ConnectionPortItem(QGraphicsEllipseItem):
|
||||
def __init__(self, endpoint: Endpoint, role: str, label: str, parent: QGraphicsItem) -> None:
|
||||
super().__init__(-6, -6, 12, 12, parent)
|
||||
self.endpoint = endpoint
|
||||
self.role = role
|
||||
self.setBrush(QColor("#ffffff"))
|
||||
self.setPen(QPen(QColor("#303030"), 1.5))
|
||||
self.setZValue(2)
|
||||
self.setToolTip(label)
|
||||
|
||||
|
||||
class ComponentGraphicsItem(QGraphicsObject):
|
||||
WIDTH = 128.0
|
||||
HEIGHT = 128.0
|
||||
|
||||
def __init__(self, component: Component, controller: DocumentController) -> None:
|
||||
super().__init__()
|
||||
self.component_id = component.id
|
||||
self.component = component
|
||||
self.controller = controller
|
||||
self.drag_start = QPointF()
|
||||
self.hitbox = icon_bounds(component.icon)
|
||||
self.setFlags(
|
||||
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
||||
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
||||
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
||||
)
|
||||
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
|
||||
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
|
||||
self.setTransformOriginPoint(self.hitbox.center())
|
||||
self.setRotation(component.rotation)
|
||||
|
||||
def _create_ports(self, ports, role: str, x: float) -> dict[str, ConnectionPortItem]:
|
||||
result = {}
|
||||
spacing = self.HEIGHT / (len(ports) + 1)
|
||||
for index, port in enumerate(ports, start=1):
|
||||
endpoint = Endpoint(block=self.component_id, port=port.id)
|
||||
item = ConnectionPortItem(endpoint, role, port.name, self)
|
||||
position = port.properties.get("iconPosition", {})
|
||||
item.setPos(
|
||||
float(position.get("x", x)) * self.WIDTH / self.component.icon.width,
|
||||
float(position.get("y", spacing * index)) * self.HEIGHT / self.component.icon.height,
|
||||
)
|
||||
result[port.id] = item
|
||||
return result
|
||||
|
||||
def boundingRect(self) -> QRectF: # noqa: N802
|
||||
return self.hitbox
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QPainter,
|
||||
option: QStyleOptionGraphicsItem,
|
||||
widget: QWidget | None = None,
|
||||
) -> None:
|
||||
del option, widget
|
||||
paint_icon(
|
||||
painter,
|
||||
self.component.icon,
|
||||
QRectF(0, 0, self.WIDTH, self.HEIGHT),
|
||||
)
|
||||
if self.isSelected():
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.setPen(QPen(QColor("#2563eb"), 2, Qt.PenStyle.DashLine))
|
||||
painter.drawRect(self.boundingRect())
|
||||
|
||||
def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
self.controller.activate_component(self.component_id)
|
||||
event.accept()
|
||||
|
||||
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
||||
if not self.isSelected():
|
||||
scene = self.scene()
|
||||
if scene is not None:
|
||||
scene.clearSelection()
|
||||
self.setSelected(True)
|
||||
menu = QMenu()
|
||||
options_action = menu.addAction("Component Options…")
|
||||
ports_action = menu.addAction("Port Options…")
|
||||
selected = menu.exec(event.screenPos())
|
||||
if selected is options_action:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.componentOptionsRequested.emit(self.component_id)
|
||||
elif selected is ports_action:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.componentPortOptionsRequested.emit(self.component_id)
|
||||
event.accept()
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
self.drag_start = self.pos()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
super().mouseReleaseEvent(event)
|
||||
snapped = _snapped(self.pos())
|
||||
self.setPos(snapped)
|
||||
self.controller.move_component(self.component_id, self.drag_start, snapped)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
value = _snapped(value)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.update_connections_for_block(self.component_id)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class InterfaceTerminalItem(QGraphicsObject):
|
||||
WIDTH = 110.0
|
||||
HEIGHT = 36.0
|
||||
|
||||
def __init__(self, port: Port, direction: str, controller: DocumentController) -> None:
|
||||
super().__init__()
|
||||
self.port = port
|
||||
self.direction = direction
|
||||
self.controller = controller
|
||||
self.drag_start = QPointF()
|
||||
role = "source" if direction == "input" else "target"
|
||||
self.connection_port = ConnectionPortItem(
|
||||
Endpoint(interface=port.id), role, port.name, self
|
||||
)
|
||||
connection_x = self.WIDTH if direction == "input" else 0.0
|
||||
self.connection_port.setPos(connection_x, self.HEIGHT / 2)
|
||||
self.setFlags(
|
||||
QGraphicsItem.GraphicsItemFlag.ItemIsMovable
|
||||
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
|
||||
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
|
||||
)
|
||||
self.setToolTip(f"Component {direction}: {port.name}")
|
||||
|
||||
def boundingRect(self) -> QRectF: # noqa: N802
|
||||
return QRectF(0, 0, self.WIDTH, self.HEIGHT)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QPainter,
|
||||
option: QStyleOptionGraphicsItem,
|
||||
widget: QWidget | None = None,
|
||||
) -> None:
|
||||
del option, widget
|
||||
painter.setBrush(QColor("#e5e7eb"))
|
||||
painter.setPen(QPen(QColor("#4b5563"), 1.5))
|
||||
painter.drawRoundedRect(self.boundingRect(), 4, 4)
|
||||
painter.setPen(QColor("#202020"))
|
||||
marker = "IN" if self.direction == "input" else "OUT"
|
||||
painter.drawText(
|
||||
self.boundingRect().adjusted(8, 0, -8, 0),
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
||||
f"{marker} {self.port.name}",
|
||||
)
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
self.drag_start = self.pos()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
||||
menu = QMenu()
|
||||
options_action = menu.addAction(f"{self.direction.title()} Options…")
|
||||
if menu.exec(event.screenPos()) is options_action:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.portOptionsRequested.emit(self.port.id, self.direction)
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
super().mouseReleaseEvent(event)
|
||||
snapped = _snapped(self.pos())
|
||||
self.setPos(snapped)
|
||||
self.controller.move_interface_port(self.port.id, self.drag_start, snapped)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
||||
value = _snapped(value)
|
||||
elif change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.update_connections_for_interface(self.port.id)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class ConnectionGraphicsItem(QGraphicsPathItem):
|
||||
def __init__(self, connection_id: str, name: str = "") -> None:
|
||||
super().__init__()
|
||||
self.connection_id = connection_id
|
||||
self.name = name
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self._update_pen()
|
||||
self.setZValue(-1)
|
||||
self.setToolTip(name or "Connection")
|
||||
|
||||
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: # noqa: N802
|
||||
menu = QMenu()
|
||||
options_action = menu.addAction("Connection Options…")
|
||||
if menu.exec(event.screenPos()) is options_action:
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.connectionOptionsRequested.emit(self.connection_id)
|
||||
event.accept()
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
result = super().itemChange(change, value)
|
||||
if change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
|
||||
self._update_pen()
|
||||
return result
|
||||
|
||||
def _update_pen(self) -> None:
|
||||
self.setPen(
|
||||
QPen(
|
||||
QColor("#f59e0b") if self.isSelected() else QColor("#285f9e"),
|
||||
4.0 if self.isSelected() else 2.5,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GraphScene(QGraphicsScene):
|
||||
componentOptionsRequested = Signal(str)
|
||||
componentPortOptionsRequested = Signal(str)
|
||||
portOptionsRequested = Signal(str, str)
|
||||
connectionOptionsRequested = Signal(str)
|
||||
|
||||
def __init__(self, controller: DocumentController, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.component_items: dict[str, ComponentGraphicsItem] = {}
|
||||
self.input_items: dict[str, InterfaceTerminalItem] = {}
|
||||
self.output_items: dict[str, InterfaceTerminalItem] = {}
|
||||
self.connection_items: dict[str, ConnectionGraphicsItem] = {}
|
||||
self.pending_source: ConnectionPortItem | None = None
|
||||
self.setSceneRect(-2000, -2000, 4000, 4000)
|
||||
|
||||
controller.documentReset.connect(self.rebuild)
|
||||
controller.activeGraphChanged.connect(self.rebuild)
|
||||
controller.componentMoved.connect(self.set_component_position)
|
||||
controller.componentRotated.connect(self.set_component_rotation)
|
||||
self.rebuild()
|
||||
|
||||
def rebuild(self) -> None:
|
||||
self.clear()
|
||||
self.component_items.clear()
|
||||
self.input_items.clear()
|
||||
self.output_items.clear()
|
||||
self.connection_items.clear()
|
||||
self.pending_source = None
|
||||
owner = self.controller.active_component
|
||||
if owner is None or owner.implementation_kind != "graph":
|
||||
return
|
||||
for port in owner.inputs:
|
||||
item = InterfaceTerminalItem(port, "input", self.controller)
|
||||
self.addItem(item)
|
||||
item.setPos(port.x, port.y)
|
||||
self.input_items[port.id] = item
|
||||
for port in owner.outputs:
|
||||
item = InterfaceTerminalItem(port, "output", self.controller)
|
||||
self.addItem(item)
|
||||
item.setPos(port.x, port.y)
|
||||
self.output_items[port.id] = item
|
||||
for component in owner.graph.blocks.values():
|
||||
item = ComponentGraphicsItem(component, self.controller)
|
||||
self.addItem(item)
|
||||
item.setPos(component.x, component.y)
|
||||
self.component_items[component.id] = item
|
||||
for connection in owner.graph.connections.values():
|
||||
item = ConnectionGraphicsItem(connection.id, connection.name)
|
||||
self.addItem(item)
|
||||
self.connection_items[connection.id] = item
|
||||
self.update_connection(connection.id)
|
||||
|
||||
def set_component_position(self, component_id: str, position: QPointF) -> None:
|
||||
item = self.component_items.get(component_id)
|
||||
if item is not None and item.pos() != position:
|
||||
item.setPos(position)
|
||||
|
||||
def set_component_rotation(self, component_id: str, rotation: float) -> None:
|
||||
item = self.component_items.get(component_id)
|
||||
if item is not None:
|
||||
item.setRotation(rotation)
|
||||
self.update_connections_for_block(component_id)
|
||||
|
||||
def update_connections_for_block(self, component_id: str) -> None:
|
||||
for connection in self.controller.active_graph.connections.values():
|
||||
if component_id in (connection.source.block, connection.target.block):
|
||||
self.update_connection(connection.id)
|
||||
|
||||
def update_connections_for_interface(self, port_id: str) -> None:
|
||||
for connection in self.controller.active_graph.connections.values():
|
||||
if port_id in (connection.source.interface, connection.target.interface):
|
||||
self.update_connection(connection.id)
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
||||
# The view paints the grid so it always covers the complete viewport.
|
||||
del painter, rect
|
||||
|
||||
def _endpoint_item(self, endpoint: Endpoint, role: str) -> ConnectionPortItem | None:
|
||||
if endpoint.interface is not None:
|
||||
terminals = self.input_items if role == "source" else self.output_items
|
||||
terminal = terminals.get(endpoint.interface)
|
||||
return terminal.connection_port if terminal else None
|
||||
component = self.component_items.get(endpoint.block or "")
|
||||
if component is None:
|
||||
return None
|
||||
ports = component.output_ports if role == "source" else component.input_ports
|
||||
return ports.get(endpoint.port or "")
|
||||
|
||||
def update_connection(self, connection_id: str) -> None:
|
||||
connection = self.controller.active_graph.connections.get(connection_id)
|
||||
graphics = self.connection_items.get(connection_id)
|
||||
if connection is None or graphics is None:
|
||||
return
|
||||
source = self._endpoint_item(connection.source, "source")
|
||||
target = self._endpoint_item(connection.target, "target")
|
||||
if source is None or target is None:
|
||||
return
|
||||
start, end = source.scenePos(), target.scenePos()
|
||||
distance = max(50.0, abs(end.x() - start.x()) * 0.5)
|
||||
path = QPainterPath(start)
|
||||
path.cubicTo(start + QPointF(distance, 0), end - QPointF(distance, 0), end)
|
||||
graphics.setPath(path)
|
||||
|
||||
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: # noqa: N802
|
||||
item = self.itemAt(event.scenePos(), QTransform())
|
||||
if isinstance(item, ConnectionPortItem):
|
||||
if item.role == "source":
|
||||
self._clear_pending_source()
|
||||
self.pending_source = item
|
||||
item.setBrush(QColor("#f5b642"))
|
||||
elif self.pending_source is not None:
|
||||
if self.pending_source.endpoint != item.endpoint:
|
||||
try:
|
||||
self.controller.connect(self.pending_source.endpoint, item.endpoint)
|
||||
except ValueError as error:
|
||||
QToolTip.showText(event.screenPos(), str(error))
|
||||
self._clear_pending_source()
|
||||
event.accept()
|
||||
return
|
||||
self._clear_pending_source()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def _clear_pending_source(self) -> None:
|
||||
if self.pending_source is not None:
|
||||
self.pending_source.setBrush(QColor("#ffffff"))
|
||||
self.pending_source = None
|
||||
|
||||
|
||||
class GraphWorkspaceView(QGraphicsView):
|
||||
toolUsed = Signal()
|
||||
componentOptionsRequested = Signal(str)
|
||||
componentPortOptionsRequested = Signal(str)
|
||||
portOptionsRequested = Signal(str, str)
|
||||
connectionOptionsRequested = Signal(str)
|
||||
selectionAvailabilityChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller: DocumentController | None = None
|
||||
self.tool_mode = "pointer"
|
||||
self.paste_count = 0
|
||||
self.setAcceptDrops(True)
|
||||
self.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||
self.setBackgroundBrush(QColor("#f7f7f7"))
|
||||
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
||||
|
||||
def drawBackground(self, painter: QPainter, rect: QRectF) -> None: # noqa: N802
|
||||
"""Paint the visible graph viewport in scene coordinates."""
|
||||
painter.fillRect(rect, QColor("#f7f7f7"))
|
||||
if (
|
||||
self.controller is None
|
||||
or self.controller.document is None
|
||||
or self.controller.active_component is None
|
||||
):
|
||||
return
|
||||
spacing = _graph_grid_size()
|
||||
left = int(rect.left()) - (int(rect.left()) % spacing)
|
||||
top = int(rect.top()) - (int(rect.top()) % spacing)
|
||||
painter.setPen(QPen(QColor("#c5cbd1"), 0))
|
||||
for x in range(left, int(rect.right()) + spacing, spacing):
|
||||
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
|
||||
for y in range(top, int(rect.bottom()) + spacing, spacing):
|
||||
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
|
||||
|
||||
def _zoom(self, factor: float) -> None:
|
||||
current = self.transform().m11()
|
||||
target = current * factor
|
||||
if 0.1 <= target <= 8.0:
|
||||
self.scale(factor, factor)
|
||||
|
||||
def zoom_in(self) -> None:
|
||||
self._zoom(1.2)
|
||||
|
||||
def zoom_out(self) -> None:
|
||||
self._zoom(1 / 1.2)
|
||||
|
||||
def center_workspace(self) -> None:
|
||||
scene = self.scene()
|
||||
if scene is None:
|
||||
return
|
||||
bounds = scene.itemsBoundingRect()
|
||||
if bounds.isEmpty():
|
||||
self.resetTransform()
|
||||
self.centerOn(0, 0)
|
||||
else:
|
||||
self.fitInView(bounds.adjusted(-80, -80, 80, 80), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802
|
||||
self._zoom(1.2 if event.angleDelta().y() > 0 else 1 / 1.2)
|
||||
event.accept()
|
||||
|
||||
def set_model(self, controller: DocumentController) -> None:
|
||||
self.controller = controller
|
||||
scene = GraphScene(controller, self)
|
||||
scene.componentOptionsRequested.connect(self.componentOptionsRequested)
|
||||
scene.componentPortOptionsRequested.connect(self.componentPortOptionsRequested)
|
||||
scene.portOptionsRequested.connect(self.portOptionsRequested)
|
||||
scene.connectionOptionsRequested.connect(self.connectionOptionsRequested)
|
||||
scene.selectionChanged.connect(
|
||||
lambda: self.selectionAvailabilityChanged.emit(bool(scene.selectedItems()))
|
||||
)
|
||||
self.setScene(scene)
|
||||
|
||||
def select_all(self) -> None:
|
||||
scene = self.scene()
|
||||
if scene is None:
|
||||
return
|
||||
for item in scene.items():
|
||||
if item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIsSelectable:
|
||||
item.setSelected(True)
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
if self.controller is None or not isinstance(self.scene(), GraphScene):
|
||||
return
|
||||
blocks: set[str] = set()
|
||||
connections: set[str] = set()
|
||||
inputs: set[str] = set()
|
||||
outputs: set[str] = set()
|
||||
for item in self.scene().selectedItems():
|
||||
if isinstance(item, ComponentGraphicsItem):
|
||||
blocks.add(item.component_id)
|
||||
elif isinstance(item, ConnectionGraphicsItem):
|
||||
connections.add(item.connection_id)
|
||||
elif isinstance(item, InterfaceTerminalItem):
|
||||
(inputs if item.direction == "input" else outputs).add(item.port.id)
|
||||
self.controller.delete_selection(blocks, connections, inputs, outputs)
|
||||
|
||||
def has_selected_components(self) -> bool:
|
||||
scene = self.scene()
|
||||
return bool(
|
||||
scene
|
||||
and any(isinstance(item, ComponentGraphicsItem) for item in scene.selectedItems())
|
||||
)
|
||||
|
||||
def rotate_selected(self) -> None:
|
||||
if self.controller is None or self.scene() is None:
|
||||
return
|
||||
component_ids = {
|
||||
item.component_id
|
||||
for item in self.scene().selectedItems()
|
||||
if isinstance(item, ComponentGraphicsItem)
|
||||
}
|
||||
self.controller.rotate_components(component_ids)
|
||||
|
||||
def copy_selection(self) -> bool:
|
||||
if self.controller is None or not isinstance(self.scene(), GraphScene):
|
||||
return False
|
||||
selected_ids = {
|
||||
item.component_id
|
||||
for item in self.scene().selectedItems()
|
||||
if isinstance(item, ComponentGraphicsItem)
|
||||
}
|
||||
if not selected_ids:
|
||||
return False
|
||||
graph = self.controller.active_graph
|
||||
components = [graph.blocks[component_id].to_dict() for component_id in selected_ids]
|
||||
connections = [
|
||||
connection.to_dict()
|
||||
for connection in graph.connections.values()
|
||||
if connection.source.block in selected_ids and connection.target.block in selected_ids
|
||||
]
|
||||
mime_data = QMimeData()
|
||||
mime_data.setData(
|
||||
SELECTION_MIME_TYPE,
|
||||
json.dumps({"components": components, "connections": connections}).encode("utf-8"),
|
||||
)
|
||||
QApplication.clipboard().setMimeData(mime_data)
|
||||
self.paste_count = 0
|
||||
return True
|
||||
|
||||
def cut_selection(self) -> None:
|
||||
if self.copy_selection():
|
||||
self.delete_selected()
|
||||
|
||||
def paste_selection(self) -> None:
|
||||
if self.controller is None:
|
||||
return
|
||||
mime_data = QApplication.clipboard().mimeData()
|
||||
if not mime_data.hasFormat(SELECTION_MIME_TYPE):
|
||||
return
|
||||
try:
|
||||
payload = json.loads(bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8"))
|
||||
components = [Component.from_dict(item) for item in payload.get("components", [])]
|
||||
connections = [Connection.from_dict(item) for item in payload.get("connections", [])]
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return
|
||||
self.paste_count += 1
|
||||
new_ids = self.controller.paste_selection(
|
||||
components,
|
||||
connections,
|
||||
QPointF(32 * self.paste_count, 32 * self.paste_count),
|
||||
)
|
||||
scene = self.scene()
|
||||
if isinstance(scene, GraphScene):
|
||||
scene.clearSelection()
|
||||
for component_id in new_ids:
|
||||
item = scene.component_items.get(component_id)
|
||||
if item is not None:
|
||||
item.setSelected(True)
|
||||
|
||||
def set_tool_mode(self, mode: str) -> None:
|
||||
self.tool_mode = mode
|
||||
self.setDragMode(
|
||||
QGraphicsView.DragMode.RubberBandDrag
|
||||
if mode == "pointer"
|
||||
else QGraphicsView.DragMode.NoDrag
|
||||
)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
|
||||
if (
|
||||
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
|
||||
and self.controller is not None
|
||||
and self.controller.active_component is not None
|
||||
and self.controller.active_component.implementation_kind == "graph"
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
super().dragEnterEvent(event)
|
||||
|
||||
def dragMoveEvent(self, event) -> None: # noqa: N802
|
||||
if (
|
||||
event.mimeData().hasFormat(COMPONENT_MIME_TYPE)
|
||||
and self.controller is not None
|
||||
and self.controller.active_component is not None
|
||||
and self.controller.active_component.implementation_kind == "graph"
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
super().dragMoveEvent(event)
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
|
||||
if self.controller is None or not event.mimeData().hasFormat(COMPONENT_MIME_TYPE):
|
||||
super().dropEvent(event)
|
||||
return
|
||||
data = json.loads(bytes(event.mimeData().data(COMPONENT_MIME_TYPE)).decode("utf-8"))
|
||||
source = Component.from_dict(data)
|
||||
self.controller.add_component_copy(
|
||||
source, _snapped(self.mapToScene(event.position().toPoint()))
|
||||
)
|
||||
event.acceptProposedAction()
|
||||
Reference in New Issue
Block a user