From 09a9e3f12fb3e3f13e5b066cc73459b4875ac873 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Mon, 27 Jul 2026 13:08:15 +0200 Subject: [PATCH] Start of the icon editor --- src/bedit_gui/commands/change_icon_command.py | 23 +++ .../controllers/document_tree_controller.py | 17 ++ src/bedit_gui/documents/document.py | 62 ++++++- src/bedit_gui/models.py | 84 +++++++++ .../resources/icons/dialog-close.png | Bin 0 -> 2085 bytes src/bedit_gui/resources/resources.qrc | 1 + src/bedit_gui/services/document_files.py | 12 +- src/bedit_gui/ui/forms/icon_editor_window.ui | 168 ++++++++++++++++++ src/bedit_gui/views/icon_editor_window.py | 159 +++++++++++++++++ src/bedit_gui/views/icon_graphics_scene.py | 123 +++++++++++++ untitled.bedit.json | 42 ++++- 11 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 src/bedit_gui/commands/change_icon_command.py create mode 100644 src/bedit_gui/models.py create mode 100644 src/bedit_gui/resources/icons/dialog-close.png create mode 100644 src/bedit_gui/ui/forms/icon_editor_window.ui create mode 100644 src/bedit_gui/views/icon_editor_window.py create mode 100644 src/bedit_gui/views/icon_graphics_scene.py diff --git a/src/bedit_gui/commands/change_icon_command.py b/src/bedit_gui/commands/change_icon_command.py new file mode 100644 index 0000000..f4a8f09 --- /dev/null +++ b/src/bedit_gui/commands/change_icon_command.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from copy import deepcopy + +from PySide6.QtGui import QUndoCommand + +from bedit_core.models import ComponentID +from bedit_gui.models import Icon + + +class ChangeIconCommand(QUndoCommand): + def __init__(self, document: object, component_id: ComponentID, icon: Icon) -> None: + super().__init__("Change icon") + self.document = document + self.component_id = component_id + self.old_icon = document.stored_component_icon(component_id) + self.new_icon = deepcopy(icon) + + def redo(self) -> None: + self.document._set_component_icon(self.component_id, self.new_icon) + + def undo(self) -> None: + self.document._set_component_icon(self.component_id, self.old_icon) diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index 49c6a87..6d8fe9c 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from functools import partial from typing import Protocol from PySide6.QtCore import QObject, QPoint, Qt @@ -9,6 +10,7 @@ from bedit_core.models import Document as CoreDocument from bedit_gui.documents import Document from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog +from bedit_gui.views.icon_editor_window import IconEditorWindow from bedit_gui.views.main_window import MainWindow from bedit_gui.views.models.document_tree_model import DocumentTreeModel @@ -48,6 +50,7 @@ class DocumentTreeController(QObject): self.model = DocumentTreeModel() self.interface_editor_factory = interface_editor_factory self.param_editor_factory = param_editor_factory + self._icon_editors: list[IconEditorWindow] = [] window.ui.documentTree.setModel(self.model) document.model_changed.connect(self._on_document_changed) @@ -81,6 +84,7 @@ class DocumentTreeController(QObject): menu = QMenu(self.window.ui.documentTree) edit_interface = menu.addAction("Edit Interface") edit_params = menu.addAction("Edit Parameters") + edit_icon = menu.addAction("Edit Icon") selected = menu.exec( self.window.ui.documentTree.viewport().mapToGlobal(position) ) @@ -88,6 +92,8 @@ class DocumentTreeController(QObject): self._edit_interface(component) elif selected is edit_params: self._edit_params(component) + elif selected is edit_icon: + self._edit_icon(component) def _edit_interface(self, component: Component) -> None: dialog = self.interface_editor_factory( @@ -105,3 +111,14 @@ class DocumentTreeController(QObject): if dialog.exec() == QDialog.DialogCode.Accepted: self.document.update_component_params(component, dialog.params()) + def _edit_icon(self, component: Component) -> None: + component_id = self.document.component_id(component) + editor = IconEditorWindow(self.document.component_icon(component_id), self.window) + editor.saved.connect(partial(self.document.change_icon, component_id)) + editor.destroyed.connect(partial(self._icon_editor_closed, editor)) + self._icon_editors.append(editor) + editor.show() + + def _icon_editor_closed(self, editor: IconEditorWindow, *_args: object) -> None: + if editor in self._icon_editors: + self._icon_editors.remove(editor) diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index be95abe..f0b2f33 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -1,16 +1,19 @@ from __future__ import annotations +from copy import deepcopy from pathlib import Path from PySide6.QtCore import QObject, Signal from PySide6.QtGui import QUndoStack -from bedit_core.models import ID, Component, Port, PortID, Parameter, ParameterID +from bedit_core.models import ID, Component, ComponentID, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Document as CoreDocument +from bedit_gui.commands.change_icon_command import ChangeIconCommand from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand +from bedit_gui.models import Icon, IconDatabase from bedit_gui.services import document_files @@ -20,6 +23,7 @@ class Document(QObject): model_changed = Signal(object) path_changed = Signal(object) modified_changed = Signal(bool) + icon_changed = Signal(object, object) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -89,6 +93,60 @@ class Document(QObject): def rename_component(self, component: Component, name: str) -> None: self.undo_stack.push(RenameComponentCommand(self, component, name)) + def component_id(self, component: Component) -> ComponentID: + def find(components: dict[ComponentID, Component]) -> ComponentID | None: + for component_id, candidate in components.items(): + if candidate is component: + return component_id + if isinstance(candidate.implementation, GraphImplementation): + found = find(candidate.implementation.graph.components) + if found is not None: + return found + return None + + component_id = find(self.model.root) + if component_id is None: + raise ValueError("component is not part of this document") + return component_id + + def stored_component_icon(self, component_id: ComponentID) -> Icon | None: + database = self._icon_database(False) + return deepcopy(database.icons.get(component_id)) if database is not None else None + + def component_icon(self, component_id: ComponentID) -> Icon: + return self.stored_component_icon(component_id) or Icon() + + def change_icon(self, component_id: ComponentID, icon: Icon) -> None: + self.undo_stack.push(ChangeIconCommand(self, component_id, icon)) + + def _set_component_icon(self, component_id: ComponentID, icon: Icon | None) -> None: + if icon is None: + database = self._icon_database(False) + if database is not None: + database.icons.pop(component_id, None) + if not database.icons: + self.model.metadata.pop("icon_database", None) + else: + self._icon_database(True).icons[component_id] = deepcopy(icon) + self.icon_changed.emit(component_id, self.stored_component_icon(component_id)) + + def _icon_database(self, create: bool) -> IconDatabase | None: + metadata = self.model.metadata + value = metadata.get("icon_database") if metadata is not None else None + if isinstance(value, dict): + value = IconDatabase.from_data(value) + metadata["icon_database"] = value + if isinstance(value, IconDatabase): + return value + if not create: + return None + if metadata is None: + metadata = {} + self.model.metadata = metadata + database = IconDatabase() + metadata["icon_database"] = database + return database + def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None: current = component.interface.ports removed = [ @@ -113,6 +171,7 @@ class Document(QObject): self.undo_stack.push(command) self.undo_stack.endMacro() + def update_component_params(self, component: Component, params: dict[ParameterID, Parameter]) -> None: current = component.parameters removed = [ @@ -136,4 +195,3 @@ class Document(QObject): for command in commands: self.undo_stack.push(command) self.undo_stack.endMacro() - diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py new file mode 100644 index 0000000..551463d --- /dev/null +++ b/src/bedit_gui/models.py @@ -0,0 +1,84 @@ +"""GUI document data stored inside the core document metadata field.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from bedit_core.models import ComponentID, ID + + +class ShapeID(ID): + pass + +@dataclass +class Shape: + layer: int + type: str | None = None + pos: tuple[int, int] = (0, 0) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> Shape: + if cls is Shape and data.get("type") == "rectangle": + return Rectangle.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]))) + + def to_data(self) -> dict[str, Any]: + return {"layer": self.layer, "type": self.type, "pos": list(self.pos)} + + +class LineType(Enum): + NONE = "none" + SOLID = "solid" + DASHED = "dashed" + DOTTED = "dotted" + DASH_DOT = "dash_dot" + + +@dataclass +class Rectangle(Shape): + type: str = field(init=False, default="rectangle") + width: float = 100.0 + height: float = 100.0 + line_type: LineType = LineType.SOLID + line_thickness: float = 1.0 + corner_radius: float = 0.0 + line_color: str = "#000000" + fill_color: str = "#ffffff" + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> Rectangle: + pos = data.get("pos", [0, 0]) + return cls(layer=int(data.get("layer", 0)), pos=(int(pos[0]), int(pos[1])), width=float(data.get("width", 100.0)), height=float(data.get("height", 100.0)), line_type=LineType(data.get("line_type", "solid")), line_thickness=float(data.get("line_thickness", 1.0)), corner_radius=float(data.get("corner_radius", 0.0)), line_color=str(data.get("line_color", "#000000")), fill_color=str(data.get("fill_color", "#ffffff"))) + + def to_data(self) -> dict[str, Any]: + return {**super().to_data(), "width": self.width, "height": self.height, "line_type": self.line_type.value, "line_thickness": self.line_thickness, "corner_radius": self.corner_radius, "line_color": self.line_color, "fill_color": self.fill_color} + + +@dataclass +class Icon: + shapes: dict[ShapeID, Shape] = field(default_factory=dict) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> Icon: + shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()} + return cls(shapes=shapes) + + def to_data(self) -> dict[str, Any]: + return {"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}} + + +@dataclass +class IconDatabase: + format_version: int = 1 + icons: dict[ComponentID, Icon] = field(default_factory=dict) + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> IconDatabase: + icons = {ComponentID(key): Icon.from_data(value) for key, value in data.get("icons", {}).items()} + return cls(format_version=int(data.get("format_version", 1)), icons=icons) + + def to_data(self) -> dict[str, Any]: + return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}} diff --git a/src/bedit_gui/resources/icons/dialog-close.png b/src/bedit_gui/resources/icons/dialog-close.png new file mode 100644 index 0000000000000000000000000000000000000000..b049b6886af97b3720f372ad573a3f69c1f12f88 GIT binary patch literal 2085 zcmV+=2-^3FP)Zm*P4U6w0xYjrCE)Zf4oAAD7VF9^(1p$br4$U4ww!+!+cGT_b4 z&jz#k5yg+l156t?4F0tR3~&W1mm@Z2-d2DrB)K=~Ea zhhcZYaR?|8WHtBP!?l5EVh?&cAdi!=dKKh73IWm{bml^+!2N+0okxh3A z4ZXNV-!L2jBb{WR`!Y_|80wyUlC0y9>gd1~q9!g~N+}SLgj=41vDYS`JP6|_!fT}v zC`TMDT7Xum(L0o6($~Jjo6PvT(#)F4%P*rBn*T!mGHPIxqOdsG>)28 zYbn;4xr>?k$Y$ER6C#ZSxonco?l?~MR2Kf^m#F>#06~%9j13z|CK4EZeMHBOWq54B z2kRF>aAX3()$l?Ts!J)T2vAa2iWb!Aj$a_z*+KQv1{6gBpkzWdk?QH3_}d=(2l~-! z7qI*nub>5j6f1eAmBWueLb;(6GHskx3UhD}6AFkEv;`+B>QJ;>s%`$|g-ES~H8TL^rFS-!+Q7>vRiyA6m;;Gesp9W?MT( zB8dxFfB`rV{|CI|1C&OX;{(3{8sLk9s#!QgorD!1GsXpIIkbcQhQXSjZ@VI*R-{tE z$m;y~6nh?AO|3G(Sk;CB9dJprYRB(Y| zqly$$Spjorz_C3$IJ|2+*Osa0+4{{jR0NYup8}C`IiHeTLRAEce`F9X3Q%k?qySm2 z12|sy%x3b?mVmltjV%A(!(3bbV2Fo*_a_F;5d8)a0xjYpEP?7JETJR4;-;Zj0~92o zlM^t(wL}-lq@ndZxSwfc!_KB*vg_5{hDO$IZQ|q35Xqd3RxJn-P_(ONJg%NA>%iM^ zRs|br93uyoE|79A#LEdxWB=7CqH0qwXLpx@2-QR$Oi=Z z`_DHpDb_>HWaz)pOsXrvgavE7I~U5uLRDHli;IU|Cy!2a+nQZyYdDM_#N8cX`;M-9ykWaHZ>47dx!=>alke~ z72RJTI^RsLH$klaOPttpAGy|lq9mky#Ri{@-}oy$b_&`7d*thNco}?dGwf3#W&kGO zhC!OrGczktAh9K>ZF$|0kF1#keE zWY5F7t?>Q*@Bxs%(L13mc`|nZT4V6(99R^ENT9fcx}exnjgtj8BcbJ`;}W10C`W$o zfPFs}X5NPuAOkozwJ!hyz?kVU^J!Q=3+`7SYDjL(;v_Qa4~CG}2jJA-;SVptTR=CE z1)N*j8F}MIfvOsq@&i~k6PCrGJ^+=@=t0a3T + icons/dialog-close.png icons/list-remove.png icons/list-add.png icons/view-form-table.png diff --git a/src/bedit_gui/services/document_files.py b/src/bedit_gui/services/document_files.py index 73b4715..20084e7 100644 --- a/src/bedit_gui/services/document_files.py +++ b/src/bedit_gui/services/document_files.py @@ -1,17 +1,25 @@ from __future__ import annotations +from copy import deepcopy from pathlib import Path from bedit_core.models import Document from bedit_core.serialization import load as load_document from bedit_core.serialization import save as save_document +from bedit_gui.models import IconDatabase def load(path: str | Path) -> Document: """Load a supported document file into the core model.""" - return load_document(path) + document = load_document(path) + if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict): + document.metadata["icon_database"] = IconDatabase.from_data(document.metadata["icon_database"]) + return document def save(document: Document, path: str | Path) -> None: """Save a core model using the format selected by its file extension.""" - save_document(document, path) + saved_document = deepcopy(document) + if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase): + saved_document.metadata["icon_database"] = saved_document.metadata["icon_database"].to_data() + save_document(saved_document, path) diff --git a/src/bedit_gui/ui/forms/icon_editor_window.ui b/src/bedit_gui/ui/forms/icon_editor_window.ui new file mode 100644 index 0000000..b77f8a7 --- /dev/null +++ b/src/bedit_gui/ui/forms/icon_editor_window.ui @@ -0,0 +1,168 @@ + + + iconEditor + + + + 0 + 0 + 800 + 600 + + + + MainWindow + + + + :/icons/icons/draw-path.png:/icons/icons/draw-path.png + + + + + + + + + + + + 0 + 0 + 800 + 22 + + + + + Edit + + + + + + + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + + + toolBar + + + TopToolBarArea + + + false + + + + + + + :/icons/icons/edit-undo.png:/icons/icons/edit-undo.png + + + Undo + + + Undo + + + Ctrl+Z + + + QAction::MenuRole::NoRole + + + + + + :/icons/icons/edit-redo.png:/icons/icons/edit-redo.png + + + Redo + + + Redo + + + Ctrl+Y + + + QAction::MenuRole::NoRole + + + + + + :/icons/icons/document-save.png:/icons/icons/document-save.png + + + Save + + + Save icon + + + Return + + + QAction::MenuRole::NoRole + + + + + + :/icons/icons/dialog-close.png:/icons/icons/dialog-close.png + + + Cancel + + + Cancel icon editing + + + Shift+Esc + + + QAction::MenuRole::NoRole + + + + + + :/icons/icons/draw-rectangle.png:/icons/icons/draw-rectangle.png + + + Add Rectangle + + + Add a rectangle + + + QAction::MenuRole::NoRole + + + + + + + + diff --git a/src/bedit_gui/views/icon_editor_window.py b/src/bedit_gui/views/icon_editor_window.py new file mode 100644 index 0000000..7b9b3e1 --- /dev/null +++ b/src/bedit_gui/views/icon_editor_window.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from copy import deepcopy + +from PySide6.QtCore import QEvent, QObject, Qt, Signal +from PySide6.QtGui import QKeySequence, QPainter, QShortcut, QUndoCommand, QUndoStack, QWheelEvent +from PySide6.QtWidgets import QGraphicsView, QMainWindow, QWidget + +from bedit_gui.models import Icon, Shape, ShapeID +from bedit_gui.ui.generated.ui_icon_editor_window import Ui_iconEditor +from bedit_gui.views.icon_graphics_scene import IconGraphicsScene, RectangleCreationTool + + +class ChangeIconDraftCommand(QUndoCommand): + def __init__(self, editor: IconEditorWindow, icon: Icon, text: str) -> None: + super().__init__(text) + self.editor = editor + self.old_icon = editor.icon() + self.new_icon = deepcopy(icon) + + def redo(self) -> None: + self.editor._set_icon(self.new_icon) + + def undo(self) -> None: + self.editor._set_icon(self.old_icon) + + +class AddShapeCommand(QUndoCommand): + def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, shape: Shape) -> None: + super().__init__(f"Add {shape.type}") + self.editor = editor + self.shape_id = shape_id + self.shape = deepcopy(shape) + + def redo(self) -> None: + self.editor._add_shape(self.shape_id, self.shape) + + def undo(self) -> None: + self.editor._remove_shape(self.shape_id) + + +class IconEditorWindow(QMainWindow): + """Independent icon editing session with its own undo stack.""" + + saved = Signal(object) + icon_changed = Signal(object) + zoom_step = 1.2 + minimum_zoom = 0.1 + maximum_zoom = 10.0 + + def __init__(self, icon: Icon, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.ui = Ui_iconEditor() + self.ui.setupUi(self) + self.setWindowTitle("Icon Editor") + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self._icon = deepcopy(icon) + + self.scene = IconGraphicsScene(self) + self.scene.set_icon(self._icon) + self.scene.shape_created.connect(self._shape_created) + self.scene.tool_active_changed.connect(self._tool_active_changed) + self.ui.graphicsView.setScene(self.scene) + self.ui.graphicsView.setRenderHint(QPainter.RenderHint.Antialiasing) + self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.ui.graphicsView.viewport().installEventFilter(self) + self.ui.actionAdd_Rectangle.setCheckable(True) + self.ui.actionAdd_Rectangle.triggered.connect(self._start_rectangle_tool) + self.cancel_tool_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) + self.cancel_tool_shortcut.activated.connect(self.scene.cancel_creation_tool) + self.zoom_in_shortcut = QShortcut(QKeySequence("Ctrl++"), self) + self.zoom_in_alt_shortcut = QShortcut(QKeySequence("Ctrl+="), self) + self.zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+-"), self) + self.zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self) + self.zoom_in_shortcut.activated.connect(self.zoom_in) + self.zoom_in_alt_shortcut.activated.connect(self.zoom_in) + self.zoom_out_shortcut.activated.connect(self.zoom_out) + self.zoom_reset_shortcut.activated.connect(self.reset_zoom) + + self.undo_stack = QUndoStack(self) + self.ui.actionUndo.triggered.connect(self.undo_stack.undo) + self.ui.actionRedo.triggered.connect(self.undo_stack.redo) + self.ui.actionSave.triggered.connect(self.save) + self.ui.actionCancel.triggered.connect(self.close) + self.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled) + self.undo_stack.canRedoChanged.connect(self.ui.actionRedo.setEnabled) + self.undo_stack.undoTextChanged.connect(self._update_undo_text) + self.undo_stack.redoTextChanged.connect(self._update_redo_text) + self.ui.actionUndo.setEnabled(False) + self.ui.actionRedo.setEnabled(False) + + def icon(self) -> Icon: + return deepcopy(self._icon) + + def apply_change(self, icon: Icon, text: str = "Edit icon") -> None: + self.undo_stack.push(ChangeIconDraftCommand(self, icon, text)) + + def save(self) -> None: + self.saved.emit(self.icon()) + self.close() + + def zoom_in(self) -> None: + self._zoom(self.zoom_step) + + def zoom_out(self) -> None: + self._zoom(1 / self.zoom_step) + + def reset_zoom(self) -> None: + self.ui.graphicsView.resetTransform() + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched is self.ui.graphicsView.viewport() and isinstance(event, QWheelEvent): + if event.angleDelta().y() == 0: + return True + self.zoom_in() if event.angleDelta().y() > 0 else self.zoom_out() + event.accept() + return True + return super().eventFilter(watched, event) + + def _zoom(self, factor: float) -> None: + current = self.ui.graphicsView.transform().m11() + target = max(self.minimum_zoom, min(self.maximum_zoom, current * factor)) + if target != current: + factor = target / current + self.ui.graphicsView.scale(factor, factor) + + def _set_icon(self, icon: Icon) -> None: + self._icon = deepcopy(icon) + self.scene.set_icon(self._icon) + self.icon_changed.emit(self.icon()) + + def _add_shape(self, shape_id: ShapeID, shape: Shape) -> None: + self._icon.shapes[shape_id] = deepcopy(shape) + self.scene.set_icon(self._icon) + self.icon_changed.emit(self.icon()) + + def _remove_shape(self, shape_id: ShapeID) -> None: + self._icon.shapes.pop(shape_id, None) + self.scene.set_icon(self._icon) + self.icon_changed.emit(self.icon()) + + def _shape_created(self, shape: Shape) -> None: + self.undo_stack.push(AddShapeCommand(self, ShapeID(), shape)) + + def _start_rectangle_tool(self) -> None: + layer = max((shape.layer for shape in self._icon.shapes.values()), default=-1) + 1 + self.scene.set_creation_tool(RectangleCreationTool(self.scene, layer)) + + def _tool_active_changed(self, active: bool) -> None: + self.ui.actionAdd_Rectangle.setChecked(active) + cursor = Qt.CursorShape.CrossCursor if active else Qt.CursorShape.ArrowCursor + self.ui.graphicsView.viewport().setCursor(cursor) + + def _update_undo_text(self, text: str) -> None: + self.ui.actionUndo.setText(f"Undo {text}" if text else "Undo") + + def _update_redo_text(self, text: str) -> None: + self.ui.actionRedo.setText(f"Redo {text}" if text else "Redo") diff --git a/src/bedit_gui/views/icon_graphics_scene.py b/src/bedit_gui/views/icon_graphics_scene.py new file mode 100644 index 0000000..481f510 --- /dev/null +++ b/src/bedit_gui/views/icon_graphics_scene.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import Protocol + +from PySide6.QtCore import QObject, QPointF, QRectF, Qt, Signal +from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen +from PySide6.QtWidgets import QGraphicsPathItem, QGraphicsRectItem, QGraphicsScene, QGraphicsSceneMouseEvent + +from bedit_gui.models import Icon, LineType, Rectangle, Shape + + +class ShapeCreationTool(Protocol): + def begin(self, position: QPointF) -> None: ... + def update(self, position: QPointF) -> None: ... + def finish(self, position: QPointF) -> Shape | None: ... + def cancel(self) -> None: ... + + +class RectangleCreationTool: + def __init__(self, scene: QGraphicsScene, layer: int) -> None: + self.scene = scene + self.layer = layer + self.start: QPointF | None = None + self.preview: QGraphicsRectItem | None = None + + def begin(self, position: QPointF) -> None: + self.start = position + self.preview = self.scene.addRect(QRectF(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.setRect(QRectF(self.start, position).normalized()) + + def finish(self, position: QPointF) -> Rectangle | None: + if self.start is None: + return None + rect = QRectF(self.start, position).normalized() + self.cancel() + if rect.width() < 1 or rect.height() < 1: + return None + return Rectangle(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height()) + + def cancel(self) -> None: + if self.preview is not None: + self.scene.removeItem(self.preview) + self.preview = None + self.start = None + + +class IconGraphicsScene(QGraphicsScene): + shape_created = Signal(object) + tool_active_changed = Signal(bool) + + def __init__(self, parent: QObject | None = None) -> None: + super().__init__(parent) + self._tool: ShapeCreationTool | None = None + self.setSceneRect(-100, -100, 200, 200) + + def set_icon(self, icon: Icon) -> None: + self.cancel_creation_tool() + self.clear() + for shape in sorted(icon.shapes.values(), key=lambda item: item.layer): + self._add_shape_item(shape) + + def set_creation_tool(self, tool: ShapeCreationTool) -> None: + self.cancel_creation_tool() + self._tool = tool + self.tool_active_changed.emit(True) + + def cancel_creation_tool(self) -> None: + if self._tool is None: + return + self._tool.cancel() + self._tool = None + self.tool_active_changed.emit(False) + + def has_creation_tool(self) -> bool: + return self._tool is not None + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self._tool is not None and event.button() == Qt.MouseButton.LeftButton: + self._tool.begin(event.scenePos()) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self._tool is not None and event.buttons() & Qt.MouseButton.LeftButton: + self._tool.update(event.scenePos()) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + if self._tool is not None and event.button() == Qt.MouseButton.LeftButton: + tool = self._tool + shape = tool.finish(event.scenePos()) + self._tool = None + self.tool_active_changed.emit(False) + if shape is not None: + self.shape_created.emit(shape) + event.accept() + return + super().mouseReleaseEvent(event) + + def _add_shape_item(self, shape: Shape) -> None: + if not isinstance(shape, Rectangle): + return + rect = QRectF(shape.pos[0], shape.pos[1], shape.width, shape.height) + path = QPainterPath() + path.addRoundedRect(rect, shape.corner_radius, shape.corner_radius) + item = QGraphicsPathItem(path) + item.setPen(self._pen(shape)) + item.setBrush(QBrush(QColor(shape.fill_color))) + item.setZValue(shape.layer) + self.addItem(item) + + @staticmethod + def _pen(shape: Rectangle) -> 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: + return QPen(Qt.PenStyle.NoPen) + return QPen(QColor(shape.line_color), shape.line_thickness, styles[shape.line_type]) diff --git a/untitled.bedit.json b/untitled.bedit.json index 618a859..73fb31c 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -396,5 +396,45 @@ } } }, - "metadata": {} + "metadata": { + "icon_database": { + "format_version": 1, + "icons": { + "43b3aee6-0b38-429b-9c6d-d38fc097297f": { + "shapes": { + "1749a693-44fe-4275-ab22-eb82db339df4": { + "layer": 0, + "type": "rectangle", + "pos": [ + -138, + -84 + ], + "width": 241.0, + "height": 217.0, + "line_type": "solid", + "line_thickness": 1.0, + "corner_radius": 0.0, + "line_color": "#000000", + "fill_color": "#ffffff" + }, + "923075c7-3d34-4d0f-902d-cda24731433a": { + "layer": 1, + "type": "rectangle", + "pos": [ + -63, + -31 + ], + "width": 72.0, + "height": 119.0, + "line_type": "solid", + "line_thickness": 1.0, + "corner_radius": 0.0, + "line_color": "#000000", + "fill_color": "#ffffff" + } + } + } + } + } + } }