diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py
index e5daddc..2e4e610 100644
--- a/src/bedit_gui/models.py
+++ b/src/bedit_gui/models.py
@@ -24,6 +24,8 @@ class Shape:
return Rectangle.from_data(data)
if cls is Shape and data.get("type") == "text":
return Text.from_data(data)
+ if cls is Shape and data.get("type") == "line":
+ return Line.from_data(data)
pos = data.get("pos", [0, 0])
return cls(layer=int(data.get("layer", 0)), type=data.get("type"), pos=(int(pos[0]), int(pos[1])))
@@ -39,6 +41,24 @@ class LineType(Enum):
DASH_DOT = "dash_dot"
+@dataclass
+class Line(Shape):
+ type: str = field(init=False, default="line")
+ end: tuple[int, int] = (32, 0)
+ line_type: LineType = LineType.SOLID
+ line_thickness: float = 1.0
+ line_color: str = "#000000ff"
+
+ @classmethod
+ def from_data(cls, data: Mapping[str, Any]) -> Line:
+ pos = data.get("pos", [0, 0])
+ end = data.get("end", [32, 0])
+ return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), end=(int(end[0]), int(end[1])), line_type=LineType(data.get("line_type", "solid")), line_thickness=float(data.get("line_thickness", 1.0)), line_color=str(data.get("line_color", "#000000ff")))
+
+ def to_data(self) -> dict[str, Any]:
+ return {**super().to_data(), "end": list(self.end), "line_type": self.line_type.value, "line_thickness": self.line_thickness, "line_color": self.line_color}
+
+
@dataclass
class Rectangle(Shape):
type: str = field(init=False, default="rectangle")
diff --git a/src/bedit_gui/ui/forms/icon_editor_window.ui b/src/bedit_gui/ui/forms/icon_editor_window.ui
index 6b0804f..8336330 100644
--- a/src/bedit_gui/ui/forms/icon_editor_window.ui
+++ b/src/bedit_gui/ui/forms/icon_editor_window.ui
@@ -81,6 +81,7 @@
+
@@ -220,6 +221,21 @@
QAction::MenuRole::NoRole
+
+
+
+ :/icons/icons/draw-path.png:/icons/icons/draw-path.png
+
+
+ Add Line
+
+
+ Add a line
+
+
+ QAction::MenuRole::NoRole
+
+
diff --git a/src/bedit_gui/views/icon_editor_window.py b/src/bedit_gui/views/icon_editor_window.py
index c298bbf..7b8eabe 100644
--- a/src/bedit_gui/views/icon_editor_window.py
+++ b/src/bedit_gui/views/icon_editor_window.py
@@ -11,7 +11,7 @@ from bedit_gui.models import Icon, Shape, ShapeID
from bedit_gui.services import icon_files
from bedit_gui.services.application_logging import get_logger
from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor
-from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool, TextCreationTool
+from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, LineCreationTool, RectangleCreationTool, TextCreationTool
from bedit_gui.views.shape_options_dialog import ShapeOptionsDialog
logger = get_logger(__name__)
@@ -125,6 +125,8 @@ class IconEditorWindow(QMainWindow):
self.ui.actionAdd_Rectangle.triggered.connect(self._start_rectangle_tool)
self.ui.actionAdd_Text.setCheckable(True)
self.ui.actionAdd_Text.triggered.connect(self._start_text_tool)
+ self.ui.actionAdd_Line.setCheckable(True)
+ self.ui.actionAdd_Line.triggered.connect(self._start_line_tool)
self.cancel_tool_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
self.cancel_tool_shortcut.activated.connect(self.scene.cancel_creation_tool)
self.delete_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Delete), self)
@@ -305,17 +307,29 @@ class IconEditorWindow(QMainWindow):
def _start_rectangle_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.ui.actionAdd_Text.setChecked(False)
+ self.ui.actionAdd_Line.setChecked(False)
self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer))
+ self.ui.actionAdd_Rectangle.setChecked(True)
def _start_text_tool(self) -> None:
layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
self.ui.actionAdd_Rectangle.setChecked(False)
+ self.ui.actionAdd_Line.setChecked(False)
self.scene.set_creation_tool(TextCreationTool(self.scene, layer))
+ self.ui.actionAdd_Text.setChecked(True)
+
+ def _start_line_tool(self) -> None:
+ layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1
+ self.ui.actionAdd_Rectangle.setChecked(False)
+ self.ui.actionAdd_Text.setChecked(False)
+ self.scene.set_creation_tool(LineCreationTool(self.scene, layer))
+ self.ui.actionAdd_Line.setChecked(True)
def _tool_active_changed(self, active: bool) -> None:
if not active:
self.ui.actionAdd_Rectangle.setChecked(False)
self.ui.actionAdd_Text.setChecked(False)
+ self.ui.actionAdd_Line.setChecked(False)
cursor = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor
self.ui.graphicsView.viewport().setCursor(cursor)
diff --git a/src/bedit_gui/views/icon_graphics_scene.py b/src/bedit_gui/views/icon_graphics_scene.py
index 1254461..2b73146 100644
--- a/src/bedit_gui/views/icon_graphics_scene.py
+++ b/src/bedit_gui/views/icon_graphics_scene.py
@@ -4,12 +4,12 @@ import math
from copy import deepcopy
from typing import Protocol
-from PySide6.QtCore import QObject, QPointF, QRectF, Qt, Signal
+from PySide6.QtCore import QLineF, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QBrush, QColor, QFont, QPainter, QPainterPath, QPen
-from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget
+from PySide6.QtWidgets import QGraphicsItem, QGraphicsLineItem, QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneContextMenuEvent, QGraphicsSceneMouseEvent, QMenu, QStyleOptionGraphicsItem, QWidget
from bedit_core.models import Port, PortID, SignalDirection
-from bedit_gui.models import Icon, LineType, Rectangle, Shape, ShapeID, Text
+from bedit_gui.models import Icon, Line, LineType, Rectangle, Shape, ShapeID, Text
class ShapeCreationTool(Protocol):
@@ -67,6 +67,45 @@ class TextCreationTool(RectangleCreationTool):
return Text(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height(), text="Text")
+class LineCreationTool:
+ def __init__(self, scene: QGraphicsScene, layer: int) -> None:
+ self.scene = scene
+ self.layer = layer
+ self.start: QPointF | None = None
+ self.preview: QGraphicsLineItem | None = None
+
+ def begin(self, position: QPointF) -> None:
+ position = self._bounded(position)
+ self.start = position
+ self.preview = self.scene.addLine(QLineF(position, position), QPen(Qt.PenStyle.DashLine))
+
+ def update(self, position: QPointF) -> None:
+ if self.preview is not None and self.start is not None:
+ self.preview.setLine(QLineF(self.start, self._bounded(position)))
+
+ def finish(self, position: QPointF) -> Shape | None:
+ if self.start is None:
+ return None
+ start = self.start
+ end = self._bounded(position)
+ self.cancel()
+ if start == end:
+ return None
+ return Line(layer=self.layer, pos=(round(start.x()), round(start.y())), end=(round(end.x()), round(end.y())))
+
+ def cancel(self) -> None:
+ if self.preview is not None:
+ self.scene.removeItem(self.preview)
+ self.preview = None
+ self.start = None
+
+ def _bounded(self, position: QPointF) -> QPointF:
+ rect = self.scene.sceneRect()
+ x = max(rect.left(), min(rect.right(), round(position.x())))
+ y = max(rect.top(), min(rect.bottom(), round(position.y())))
+ return QPointF(x, y)
+
+
class ShapeGraphicsItem(QGraphicsPathItem):
handle_size = 6.0
@@ -76,7 +115,7 @@ class ShapeGraphicsItem(QGraphicsPathItem):
self.shape_model = deepcopy(shape)
self.icon_scene = scene
self._original_shape: Shape | None = None
- self._resizing = False
+ self._resize_handle: str | None = None
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def resize_handle_rect(self) -> QRectF:
@@ -84,29 +123,32 @@ class ShapeGraphicsItem(QGraphicsPathItem):
corner = self.path().boundingRect().bottomRight()
return QRectF(corner.x() - size / 2, corner.y() - size / 2, size, size)
+ def resize_handles(self) -> dict[str, QRectF]:
+ return {"size": self.resize_handle_rect()}
+
def boundingRect(self) -> QRectF:
margin = self.handle_size / 2
return super().boundingRect().adjusted(-margin, -margin, margin, margin)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._original_shape = self.current_shape()
- self._resizing = self.isSelected() and self.resize_handle_rect().contains(event.pos())
- if self._resizing:
+ self._resize_handle = next((name for name, rect in self.resize_handles().items() if self.isSelected() and rect.contains(event.pos())), None)
+ if self._resize_handle is not None:
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
- if self._resizing:
- self.resize_to(event.scenePos())
+ if self._resize_handle is not None:
+ self.resize_to(event.scenePos(), self._resize_handle)
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
- if self._resizing:
- self.resize_to(event.scenePos())
- self._resizing = False
+ if self._resize_handle is not None:
+ self.resize_to(event.scenePos(), self._resize_handle)
+ self._resize_handle = None
event.accept()
else:
super().mouseReleaseEvent(event)
@@ -130,9 +172,9 @@ class ShapeGraphicsItem(QGraphicsPathItem):
position = value
if isinstance(position, QPointF):
bounds = self.icon_scene.sceneRect()
- size = self.path().boundingRect()
- x = max(bounds.left(), min(bounds.right() - size.width(), round(position.x())))
- y = max(bounds.top(), min(bounds.bottom() - size.height(), round(position.y())))
+ shape_rect = self.path().boundingRect()
+ x = max(bounds.left() - shape_rect.left(), min(bounds.right() - shape_rect.right(), round(position.x())))
+ y = max(bounds.top() - shape_rect.top(), min(bounds.bottom() - shape_rect.bottom(), round(position.y())))
return QPointF(x, y)
return super().itemChange(change, value)
@@ -141,12 +183,13 @@ class ShapeGraphicsItem(QGraphicsPathItem):
if self.isSelected():
painter.setPen(QPen(QColor("#ffffff")))
painter.setBrush(QBrush(QColor("#2675bf")))
- painter.drawRect(self.resize_handle_rect())
+ for rect in self.resize_handles().values():
+ painter.drawRect(rect)
def current_shape(self) -> Shape:
raise NotImplementedError
- def resize_to(self, position: QPointF) -> None:
+ def resize_to(self, position: QPointF, handle: str) -> None:
raise NotImplementedError
@@ -168,7 +211,7 @@ class RectangleGraphicsItem(ShapeGraphicsItem):
shape.height = rect.height()
return shape
- def resize_to(self, position: QPointF) -> None:
+ def resize_to(self, position: QPointF, _handle: str) -> None:
bounds = self.icon_scene.sceneRect()
width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x())
height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y())
@@ -199,7 +242,7 @@ class TextGraphicsItem(ShapeGraphicsItem):
shape.height = rect.height()
return shape
- def resize_to(self, position: QPointF) -> None:
+ def resize_to(self, position: QPointF, _handle: str) -> None:
bounds = self.icon_scene.sceneRect()
width = max(1.0, min(bounds.right(), round(position.x())) - self.pos().x())
height = max(1.0, min(bounds.bottom(), round(position.y())) - self.pos().y())
@@ -223,6 +266,45 @@ class TextGraphicsItem(ShapeGraphicsItem):
self.setPath(path)
+class LineGraphicsItem(ShapeGraphicsItem):
+ def __init__(self, shape_id: ShapeID, shape: Line, scene: IconGraphicsScene) -> None:
+ super().__init__(shape_id, shape, scene)
+ self.line = deepcopy(shape)
+ self._set_points(QPointF(shape.pos[0], shape.pos[1]), QPointF(shape.end[0], shape.end[1]))
+ self.setPen(scene._line_pen(shape.line_type, shape.line_thickness, shape.line_color))
+ self.setZValue(shape.layer)
+
+ def resize_handles(self) -> dict[str, QRectF]:
+ size = self.handle_size
+ start = self.path().pointAtPercent(0)
+ end = self.path().pointAtPercent(1)
+ return {"start": QRectF(start.x() - size / 2, start.y() - size / 2, size, size), "end": QRectF(end.x() - size / 2, end.y() - size / 2, size, size)}
+
+ def current_shape(self) -> Line:
+ shape = deepcopy(self.line)
+ start = self.mapToScene(self.path().pointAtPercent(0))
+ end = self.mapToScene(self.path().pointAtPercent(1))
+ shape.pos = (round(start.x()), round(start.y()))
+ shape.end = (round(end.x()), round(end.y()))
+ return shape
+
+ def resize_to(self, position: QPointF, handle: str) -> None:
+ bounds = self.icon_scene.sceneRect()
+ position = QPointF(max(bounds.left(), min(bounds.right(), round(position.x()))), max(bounds.top(), min(bounds.bottom(), round(position.y()))))
+ start = self.mapToScene(self.path().pointAtPercent(0))
+ end = self.mapToScene(self.path().pointAtPercent(1))
+ start = position if handle == "start" else start
+ end = position if handle == "end" else end
+ self.setPos(0, 0)
+ self._set_points(start, end)
+
+ def _set_points(self, start: QPointF, end: QPointF) -> None:
+ self.prepareGeometryChange()
+ path = QPainterPath(start)
+ path.lineTo(end)
+ self.setPath(path)
+
+
class PortGraphicsItem(QGraphicsRectItem):
size = 16.0
@@ -358,13 +440,19 @@ class IconGraphicsScene(QGraphicsScene):
self.addItem(RectangleGraphicsItem(shape_id, shape, self))
elif isinstance(shape, Text):
self.addItem(TextGraphicsItem(shape_id, shape, self))
+ elif isinstance(shape, Line):
+ self.addItem(LineGraphicsItem(shape_id, shape, self))
@staticmethod
def _pen(shape: Rectangle) -> QPen:
+ return IconGraphicsScene._line_pen(shape.line_type, shape.line_thickness, shape.line_color)
+
+ @staticmethod
+ def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
- if shape.line_type is LineType.NONE:
+ if line_type is LineType.NONE:
return QPen(Qt.PenStyle.NoPen)
- return QPen(IconGraphicsScene._color(shape.line_color), shape.line_thickness, styles[shape.line_type])
+ return QPen(IconGraphicsScene._color(color), thickness, styles[line_type])
@staticmethod
def _color(value: str) -> QColor:
diff --git a/src/bedit_gui/views/shape_options_dialog.py b/src/bedit_gui/views/shape_options_dialog.py
index 4b89a1e..3c47e80 100644
--- a/src/bedit_gui/views/shape_options_dialog.py
+++ b/src/bedit_gui/views/shape_options_dialog.py
@@ -5,7 +5,7 @@ from copy import deepcopy
from PySide6.QtCore import QRectF
from PySide6.QtWidgets import QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QLabel, QLineEdit, QSpinBox, QVBoxLayout, QWidget
-from bedit_gui.models import LineType, Rectangle, Shape, Text
+from bedit_gui.models import Line, LineType, Rectangle, Shape, Text
from bedit_gui.views.color_button import ColorButton
@@ -36,6 +36,8 @@ class ShapeOptionsDialog(QDialog):
self._add_rectangle_fields(shape)
elif isinstance(shape, Text):
self._add_text_fields(shape)
+ elif isinstance(shape, Line):
+ self._add_line_fields(shape)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
@@ -53,6 +55,8 @@ class ShapeOptionsDialog(QDialog):
width = min(self.width.value(), self._scene_rect.right() - self.x.value())
height = min(self.height.value(), self._scene_rect.bottom() - self.y.value())
return Text(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, color=self.color.color(), bold=self.bold.isChecked(), italic=self.italic.isChecked(), size=self.size.value(), text=self.text.text())
+ if isinstance(self._shape, Line):
+ return Line(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), end=(self.end_x.value(), self.end_y.value()), line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), line_color=self.line_color.color())
shape = deepcopy(self._shape)
shape.layer = self.layer.value()
shape.pos = (self.x.value(), self.y.value())
@@ -108,3 +112,26 @@ class ShapeOptionsDialog(QDialog):
self.form.addRow("Italic", self.italic)
self.form.addRow("Size", self.size)
self.form.addRow("Text", self.text)
+
+ def _add_line_fields(self, shape: Line) -> None:
+ self.x.setMaximum(round(self._scene_rect.right()))
+ self.y.setMaximum(round(self._scene_rect.bottom()))
+ self.end_x = QSpinBox()
+ self.end_x.setRange(round(self._scene_rect.left()), round(self._scene_rect.right()))
+ self.end_x.setValue(shape.end[0])
+ self.end_y = QSpinBox()
+ self.end_y.setRange(round(self._scene_rect.top()), round(self._scene_rect.bottom()))
+ self.end_y.setValue(shape.end[1])
+ self.line_type = QComboBox()
+ for line_type in LineType:
+ self.line_type.addItem(line_type.value.replace("_", " ").title(), line_type)
+ self.line_type.setCurrentIndex(self.line_type.findData(shape.line_type))
+ self.line_thickness = QDoubleSpinBox()
+ self.line_thickness.setRange(0, 1000)
+ self.line_thickness.setValue(shape.line_thickness)
+ self.line_color = ColorButton(shape.line_color)
+ self.form.addRow("End X", self.end_x)
+ self.form.addRow("End Y", self.end_y)
+ self.form.addRow("Line type", self.line_type)
+ self.form.addRow("Line thickness", self.line_thickness)
+ self.form.addRow("Line color", self.line_color)