From e5968e165e8663850172328b6bbd43aa6e937837 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 27 Jul 2026 14:40:48 +0200 Subject: [PATCH] Line in icon tool --- src/bedit_gui/models.py | 20 +++ src/bedit_gui/ui/forms/icon_editor_window.ui | 16 +++ src/bedit_gui/views/icon_editor_window.py | 16 ++- src/bedit_gui/views/icon_graphics_scene.py | 128 ++++++++++++++++--- src/bedit_gui/views/shape_options_dialog.py | 29 ++++- 5 files changed, 187 insertions(+), 22 deletions(-) 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)