Start of the icon editor
This commit is contained in:
23
src/bedit_gui/commands/change_icon_command.py
Normal file
23
src/bedit_gui/commands/change_icon_command.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
84
src/bedit_gui/models.py
Normal file
84
src/bedit_gui/models.py
Normal file
@@ -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()}}
|
||||
BIN
src/bedit_gui/resources/icons/dialog-close.png
Normal file
BIN
src/bedit_gui/resources/icons/dialog-close.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,5 +1,6 @@
|
||||
<RCC>
|
||||
<qresource prefix="icons">
|
||||
<file>icons/dialog-close.png</file>
|
||||
<file>icons/list-remove.png</file>
|
||||
<file>icons/list-add.png</file>
|
||||
<file>icons/view-form-table.png</file>
|
||||
|
||||
@@ -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)
|
||||
|
||||
168
src/bedit_gui/ui/forms/icon_editor_window.ui
Normal file
168
src/bedit_gui/ui/forms/icon_editor_window.ui
Normal file
@@ -0,0 +1,168 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>iconEditor</class>
|
||||
<widget class="QMainWindow" name="iconEditor">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>600</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>MainWindow</string>
|
||||
</property>
|
||||
<property name="windowIcon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-path.png</normaloff>:/icons/icons/draw-path.png</iconset>
|
||||
</property>
|
||||
<widget class="QWidget" name="centralwidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGraphicsView" name="graphicsView"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QMenuBar" name="menubar">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>800</width>
|
||||
<height>22</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuEdit">
|
||||
<property name="title">
|
||||
<string>Edit</string>
|
||||
</property>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
<addaction name="separator"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionCancel"/>
|
||||
</widget>
|
||||
<addaction name="menuEdit"/>
|
||||
</widget>
|
||||
<widget class="QStatusBar" name="statusbar"/>
|
||||
<widget class="QToolBar" name="actionToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionUndo"/>
|
||||
<addaction name="actionRedo"/>
|
||||
<addaction name="actionSave"/>
|
||||
<addaction name="actionCancel"/>
|
||||
</widget>
|
||||
<widget class="QToolBar" name="iconToolbar">
|
||||
<property name="windowTitle">
|
||||
<string>toolBar</string>
|
||||
</property>
|
||||
<attribute name="toolBarArea">
|
||||
<enum>TopToolBarArea</enum>
|
||||
</attribute>
|
||||
<attribute name="toolBarBreak">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<addaction name="actionAdd_Rectangle"/>
|
||||
</widget>
|
||||
<action name="actionUndo">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-undo.png</normaloff>:/icons/icons/edit-undo.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Undo</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Undo</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Z</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionRedo">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/edit-redo.png</normaloff>:/icons/icons/edit-redo.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Redo</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Redo</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Ctrl+Y</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionSave">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/document-save.png</normaloff>:/icons/icons/document-save.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Save icon</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Return</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionCancel">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/dialog-close.png</normaloff>:/icons/icons/dialog-close.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Cancel</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Cancel icon editing</string>
|
||||
</property>
|
||||
<property name="shortcut">
|
||||
<string>Shift+Esc</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
<action name="actionAdd_Rectangle">
|
||||
<property name="icon">
|
||||
<iconset resource="../../resources/resources.qrc">
|
||||
<normaloff>:/icons/icons/draw-rectangle.png</normaloff>:/icons/icons/draw-rectangle.png</iconset>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Add Rectangle</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Add a rectangle</string>
|
||||
</property>
|
||||
<property name="menuRole">
|
||||
<enum>QAction::MenuRole::NoRole</enum>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<resources>
|
||||
<include location="../../resources/resources.qrc"/>
|
||||
</resources>
|
||||
<connections/>
|
||||
</ui>
|
||||
159
src/bedit_gui/views/icon_editor_window.py
Normal file
159
src/bedit_gui/views/icon_editor_window.py
Normal file
@@ -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")
|
||||
123
src/bedit_gui/views/icon_graphics_scene.py
Normal file
123
src/bedit_gui/views/icon_graphics_scene.py
Normal file
@@ -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])
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user