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:
2026-07-20 12:09:00 +02:00
parent 1a47952358
commit 48a2b4c8d0
43 changed files with 1011 additions and 310 deletions

View 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()