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:
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()
|
||||
Reference in New Issue
Block a user