Line in icon tool

This commit is contained in:
2026-07-27 14:40:48 +02:00
parent c68e693359
commit e5968e165e
5 changed files with 187 additions and 22 deletions

View File

@@ -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")

View File

@@ -81,6 +81,7 @@
</attribute>
<addaction name="actionAdd_Rectangle"/>
<addaction name="actionAdd_Text"/>
<addaction name="actionAdd_Line"/>
</widget>
<action name="actionUndo">
<property name="icon">
@@ -220,6 +221,21 @@
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionAdd_Line">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-path.png</normaloff>:/icons/icons/draw-path.png</iconset>
</property>
<property name="text">
<string>Add Line</string>
</property>
<property name="toolTip">
<string>Add a line</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)