Compare commits

..

4 Commits

Author SHA1 Message Date
c68e693359 Text fields and saving Icons to file 2026-07-27 14:30:25 +02:00
13d3825f9b Added ports to icon 2026-07-27 14:12:13 +02:00
035cbdf53f Rectangles in incons 2026-07-27 14:03:06 +02:00
09a9e3f12f Start of the icon editor 2026-07-27 13:08:15 +02:00
21 changed files with 1644 additions and 7 deletions

5
.vscode/launch.json vendored
View File

@@ -14,7 +14,10 @@
"env": { "env": {
"QT_QPA_PLATFORMTHEME": "qt6ct", "QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_QPA_PLATFORM": "xcb" "QT_QPA_PLATFORM": "xcb"
} },
"args": [
"-f", "${workspaceFolder}/untitled.bedit.json"
]
} }
] ]
} }

25
0.icon.json Normal file
View File

@@ -0,0 +1,25 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "0"
}
},
"port_positions": {
"497b1f74-1186-471f-976a-36b07a451caf": [
-8,
-8
]
}
}

25
1.icon.json Normal file
View File

@@ -0,0 +1,25 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "1"
}
},
"port_positions": {
"4306701a-6b1b-4d19-b8ff-45dbfa04f2d3": [
-8,
-8
]
}
}

29
C.icon.json Normal file
View File

@@ -0,0 +1,29 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64.0,
"height": 64.0,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "C"
}
},
"port_positions": {
"c87881f1-23b4-4a69-b586-8e3c7bf6e21e": [
-8,
-8
],
"c62de8eb-e13b-4849-bea5-c8cc5332269e": [
16,
-32
]
}
}

29
I.icon.json Normal file
View File

@@ -0,0 +1,29 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "I"
}
},
"port_positions": {
"0b9036b1-e4e4-437e-8c35-f5bb391b6cbd": [
-8,
-8
],
"fb25f35d-4c0c-4cfa-92d4-1a18a71e2c11": [
16,
-32
]
}
}

29
R.icon.json Normal file
View File

@@ -0,0 +1,29 @@
{
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "R"
}
},
"port_positions": {
"0ac7d4ea-77f7-4c0d-8e37-406ef66e4740": [
-8,
-8
],
"d1f5aab2-7bff-4b1e-8697-98fb6c3d8f02": [
16,
-32
]
}
}

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import argparse
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
@@ -15,8 +16,22 @@ from bedit_gui.documents import Document
from bedit_gui.services.application_settings import ApplicationSettings from bedit_gui.services.application_settings import ApplicationSettings
from bedit_gui.views.main_window import MainWindow from bedit_gui.views.main_window import MainWindow
def parse_arguments():
parser = argparse.ArgumentParser(exit_on_error=False)
parser.add_argument(
"-f", "--file", type=str, help="Path to a file to open", default=None, required=False
)
return parser.parse_args()
def main() -> int: def main() -> int:
try:
args = parse_arguments()
except argparse.ArgumentError as e:
print(e.message)
return 1
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setOrganizationName("BEdit") app.setOrganizationName("BEdit")
@@ -36,7 +51,11 @@ def main() -> int:
window_state_controller = WindowStateController(app, window) window_state_controller = WindowStateController(app, window)
window_state_controller.restore() window_state_controller.restore()
if args.file:
document.open(args.file)
else:
document.new() document.new()
window.showMaximized() window.showMaximized()
return app.exec() return app.exec()

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

View File

@@ -1,4 +1,5 @@
from collections.abc import Callable from collections.abc import Callable
from functools import partial
from typing import Protocol from typing import Protocol
from PySide6.QtCore import QObject, QPoint, Qt 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.documents import Document
from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog from bedit_gui.views.dialogs.interface_editor_dialog import InterfaceEditorDialog
from bedit_gui.views.dialogs.param_editor_dialog import ParamEditorDialog 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.main_window import MainWindow
from bedit_gui.views.models.document_tree_model import DocumentTreeModel from bedit_gui.views.models.document_tree_model import DocumentTreeModel
@@ -48,6 +50,7 @@ class DocumentTreeController(QObject):
self.model = DocumentTreeModel() self.model = DocumentTreeModel()
self.interface_editor_factory = interface_editor_factory self.interface_editor_factory = interface_editor_factory
self.param_editor_factory = param_editor_factory self.param_editor_factory = param_editor_factory
self._icon_editors: list[IconEditorWindow] = []
window.ui.documentTree.setModel(self.model) window.ui.documentTree.setModel(self.model)
document.model_changed.connect(self._on_document_changed) document.model_changed.connect(self._on_document_changed)
@@ -81,6 +84,7 @@ class DocumentTreeController(QObject):
menu = QMenu(self.window.ui.documentTree) menu = QMenu(self.window.ui.documentTree)
edit_interface = menu.addAction("Edit Interface") edit_interface = menu.addAction("Edit Interface")
edit_params = menu.addAction("Edit Parameters") edit_params = menu.addAction("Edit Parameters")
edit_icon = menu.addAction("Edit Icon")
selected = menu.exec( selected = menu.exec(
self.window.ui.documentTree.viewport().mapToGlobal(position) self.window.ui.documentTree.viewport().mapToGlobal(position)
) )
@@ -88,6 +92,8 @@ class DocumentTreeController(QObject):
self._edit_interface(component) self._edit_interface(component)
elif selected is edit_params: elif selected is edit_params:
self._edit_params(component) self._edit_params(component)
elif selected is edit_icon:
self._edit_icon(component)
def _edit_interface(self, component: Component) -> None: def _edit_interface(self, component: Component) -> None:
dialog = self.interface_editor_factory( dialog = self.interface_editor_factory(
@@ -105,3 +111,14 @@ class DocumentTreeController(QObject):
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
self.document.update_component_params(component, dialog.params()) 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), component.interface.ports, 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)

View File

@@ -1,16 +1,19 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from pathlib import Path from pathlib import Path
from PySide6.QtCore import QObject, Signal from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QUndoStack 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_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.port_commands import AddPortCommand, ChangePortCommand, RemovePortCommand
from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_component_command import RenameComponentCommand
from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand
from bedit_gui.models import Icon, IconDatabase
from bedit_gui.services import document_files from bedit_gui.services import document_files
@@ -20,6 +23,7 @@ class Document(QObject):
model_changed = Signal(object) model_changed = Signal(object)
path_changed = Signal(object) path_changed = Signal(object)
modified_changed = Signal(bool) modified_changed = Signal(bool)
icon_changed = Signal(object, object)
def __init__(self, parent: QObject | None = None) -> None: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -89,6 +93,60 @@ class Document(QObject):
def rename_component(self, component: Component, name: str) -> None: def rename_component(self, component: Component, name: str) -> None:
self.undo_stack.push(RenameComponentCommand(self, component, name)) 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: def update_component_ports(self, component: Component, ports: dict[PortID, Port]) -> None:
current = component.interface.ports current = component.interface.ports
removed = [ removed = [
@@ -113,6 +171,7 @@ class Document(QObject):
self.undo_stack.push(command) self.undo_stack.push(command)
self.undo_stack.endMacro() self.undo_stack.endMacro()
def update_component_params(self, component: Component, params: dict[ParameterID, Parameter]) -> None: def update_component_params(self, component: Component, params: dict[ParameterID, Parameter]) -> None:
current = component.parameters current = component.parameters
removed = [ removed = [
@@ -136,4 +195,3 @@ class Document(QObject):
for command in commands: for command in commands:
self.undo_stack.push(command) self.undo_stack.push(command)
self.undo_stack.endMacro() self.undo_stack.endMacro()

106
src/bedit_gui/models.py Normal file
View File

@@ -0,0 +1,106 @@
"""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, PortID
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)
if cls is Shape and data.get("type") == "text":
return Text.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 = 32.0
height: float = 32.0
line_type: LineType = LineType.SOLID
line_thickness: float = 1.0
corner_radius: float = 0.0
line_color: str = "#000000ff"
fill_color: str = "#ffffff00"
@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", "#000000ff")), fill_color=str(data.get("fill_color", "#ffffff00")))
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 Text(Shape):
type: str = field(init=False, default="text")
width: float = 32.0
height: float = 16.0
color: str = "#000000ff"
bold: bool = False
italic: bool = False
size: float = 16.0
text: str = ""
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Text:
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", 32.0)), height=float(data.get("height", 16.0)), color=str(data.get("color", "#000000ff")), bold=bool(data.get("bold", False)), italic=bool(data.get("italic", False)), size=float(data.get("size", 16.0)), text=str(data.get("text", "")))
def to_data(self) -> dict[str, Any]:
return {**super().to_data(), "width": self.width, "height": self.height, "color": self.color, "bold": self.bold, "italic": self.italic, "size": self.size, "text": self.text}
@dataclass
class Icon:
shapes: dict[ShapeID, Shape] = field(default_factory=dict)
port_positions: dict[PortID, tuple[int, int]] = 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()}
port_positions = {PortID(key): (int(value[0]), int(value[1])) for key, value in data.get("port_positions", {}).items()}
return cls(shapes=shapes, port_positions=port_positions)
def to_data(self) -> dict[str, Any]:
return {"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()}, "port_positions": {str(key): list(position) for key, position in self.port_positions.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()}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,5 +1,6 @@
<RCC> <RCC>
<qresource prefix="icons"> <qresource prefix="icons">
<file>icons/dialog-close.png</file>
<file>icons/list-remove.png</file> <file>icons/list-remove.png</file>
<file>icons/list-add.png</file> <file>icons/list-add.png</file>
<file>icons/view-form-table.png</file> <file>icons/view-form-table.png</file>

View File

@@ -1,17 +1,25 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
from pathlib import Path from pathlib import Path
from bedit_core.models import Document from bedit_core.models import Document
from bedit_core.serialization import load as load_document from bedit_core.serialization import load as load_document
from bedit_core.serialization import save as save_document from bedit_core.serialization import save as save_document
from bedit_gui.models import IconDatabase
def load(path: str | Path) -> Document: def load(path: str | Path) -> Document:
"""Load a supported document file into the core model.""" """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: def save(document: Document, path: str | Path) -> None:
"""Save a core model using the format selected by its file extension.""" """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)

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import json
from pathlib import Path
from bedit_gui.models import Icon
def load(path: str | Path) -> Icon:
data = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise TypeError("icon file must contain a JSON object")
return Icon.from_data(data)
def save(icon: Icon, path: str | Path) -> None:
Path(path).write_text(json.dumps(icon.to_data(), indent=2) + "\n", encoding="utf-8")

View File

@@ -0,0 +1,228 @@
<?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"/>
</widget>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>File</string>
</property>
<addaction name="actionOpen_from_File"/>
<addaction name="actionSave_to_File"/>
<addaction name="separator"/>
<addaction name="actionSave"/>
<addaction name="actionCancel"/>
</widget>
<addaction name="menuFile"/>
<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"/>
<addaction name="actionAdd_Text"/>
</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>
<action name="actionAdd_Text">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/draw-text.png</normaloff>:/icons/icons/draw-text.png</iconset>
</property>
<property name="text">
<string>Add Text</string>
</property>
<property name="toolTip">
<string>Add a text field</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionSave_to_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-save-as.png</normaloff>:/icons/icons/document-save-as.png</iconset>
</property>
<property name="text">
<string>Save to File</string>
</property>
<property name="toolTip">
<string>Save icon to a file</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+S</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
<action name="actionOpen_from_File">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/document-open.png</normaloff>:/icons/icons/document-open.png</iconset>
</property>
<property name="text">
<string>Open from File</string>
</property>
<property name="toolTip">
<string>Open icon from File</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+O</string>
</property>
<property name="menuRole">
<enum>QAction::MenuRole::NoRole</enum>
</property>
</action>
</widget>
<resources>
<include location="../../resources/resources.qrc"/>
</resources>
<connections/>
</ui>

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QColorDialog, QPushButton, QWidget
class ColorButton(QPushButton):
def __init__(self, color: str, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("colorButton")
self._color = self._from_rgba(color)
self.clicked.connect(self._select_color)
self._update_display()
def color(self) -> str:
return f"#{self._color.red():02x}{self._color.green():02x}{self._color.blue():02x}{self._color.alpha():02x}"
def set_color(self, color: str) -> None:
self._color = self._from_rgba(color)
self._update_display()
def _select_color(self) -> None:
parent = self.window()
dialog = QColorDialog(self._color, parent if parent is not self else None)
dialog.setOption(QColorDialog.ColorDialogOption.ShowAlphaChannel)
if dialog.exec() == QColorDialog.DialogCode.Accepted:
self._color = dialog.selectedColor()
self._update_display()
def _update_display(self) -> None:
self.setText(self.color())
foreground = "#000000" if self._color.lightness() > 127 or self._color.alpha() < 128 else "#ffffff"
self.setStyleSheet(f"QPushButton#colorButton {{ background-color: rgba({self._color.red()}, {self._color.green()}, {self._color.blue()}, {self._color.alpha()}); color: {foreground}; }}")
@staticmethod
def _from_rgba(value: str) -> QColor:
color = value.removeprefix("#")
if len(color) == 6:
color += "ff"
if len(color) == 8:
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
return QColor(value)

View File

@@ -0,0 +1,326 @@
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, QShowEvent
from PySide6.QtWidgets import QDialog, QFileDialog, QGraphicsView, QMainWindow, QMessageBox, QWidget
from bedit_core.models import Port, PortID, SignalDirection
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.shape_options_dialog import ShapeOptionsDialog
logger = get_logger(__name__)
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 ChangeShapeCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None:
super().__init__(f"Change {new_shape.type}")
self.editor = editor
self.shape_id = shape_id
self.old_shape = deepcopy(old_shape)
self.new_shape = deepcopy(new_shape)
def redo(self) -> None:
self.editor._change_shape(self.shape_id, self.new_shape)
def undo(self) -> None:
self.editor._change_shape(self.shape_id, self.old_shape)
class DeleteShapesCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, shapes: dict[ShapeID, Shape]) -> None:
text = "Delete shape" if len(shapes) == 1 else "Delete shapes"
super().__init__(text)
self.editor = editor
self.shapes = deepcopy(shapes)
def redo(self) -> None:
self.editor._remove_shapes(self.shapes)
def undo(self) -> None:
self.editor._restore_shapes(self.shapes)
class MovePortCommand(QUndoCommand):
def __init__(self, editor: IconEditorWindow, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None:
super().__init__("Move port")
self.editor = editor
self.port_id = port_id
self.old_position = old_position
self.new_position = new_position
def redo(self) -> None:
self.editor._move_port(self.port_id, self.new_position)
def undo(self) -> None:
self.editor._move_port(self.port_id, self.old_position)
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, ports: dict[PortID, Port], 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._ports = deepcopy(ports)
self.scene = IconGraphicsScene(self)
self.scene.set_ports(self._ports)
self._ensure_port_positions(self._ports)
self.scene.set_icon(self._icon)
self.scene.port_moved.connect(self._port_moved)
self.scene.shape_created.connect(self._shape_created)
self.scene.shape_changed.connect(self._shape_changed)
self.scene.shape_options_requested.connect(self._show_shape_options)
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.ui.actionAdd_Text.setCheckable(True)
self.ui.actionAdd_Text.triggered.connect(self._start_text_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)
self.delete_shortcut.activated.connect(self._delete_selected_shapes)
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.actionSave_to_File.triggered.connect(self.save_to_file)
self.ui.actionOpen_from_File.triggered.connect(self.open_from_file)
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 showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self.fit_scene()
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 save_to_file(self) -> None:
file_name, _ = QFileDialog.getSaveFileName(self, "Save Icon", "", "JSON files (*.json)")
if not file_name:
return
path = file_name if file_name.lower().endswith(".json") else f"{file_name}.json"
try:
icon_files.save(self._icon, path)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not save icon to %s", path)
QMessageBox.critical(self, "Could not save icon", str(exc))
return
logger.info("Saved icon to: %s", path)
def open_from_file(self) -> None:
file_name, _ = QFileDialog.getOpenFileName(self, "Open Icon", "", "JSON files (*.json)")
if not file_name:
return
try:
icon = icon_files.load(file_name)
except (OSError, TypeError, ValueError) as exc:
logger.exception("Could not open icon from %s", file_name)
QMessageBox.critical(self, "Could not open icon", str(exc))
return
self.apply_change(icon, "Load icon from file")
logger.info("Loaded icon from: %s", file_name)
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.fit_scene()
def fit_scene(self) -> None:
self.ui.graphicsView.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
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._ensure_port_positions(self._ports)
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 _remove_shapes(self, shapes: dict[ShapeID, Shape]) -> None:
for shape_id in shapes:
self._icon.shapes.pop(shape_id, None)
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _restore_shapes(self, shapes: dict[ShapeID, Shape]) -> None:
self._icon.shapes.update(deepcopy(shapes))
self.scene.set_icon(self._icon)
self.icon_changed.emit(self.icon())
def _change_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 _move_port(self, port_id: PortID, position: tuple[int, int]) -> None:
self._icon.port_positions[port_id] = position
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 _shape_changed(self, shape_id: ShapeID, old_shape: Shape, new_shape: Shape) -> None:
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape))
def _port_moved(self, port_id: PortID, old_position: tuple[int, int], new_position: tuple[int, int]) -> None:
self.undo_stack.push(MovePortCommand(self, port_id, old_position, new_position))
def _delete_selected_shapes(self) -> None:
shape_ids = self.scene.selected_shape_ids()
shapes = {shape_id: self._icon.shapes[shape_id] for shape_id in shape_ids}
if shapes:
self.undo_stack.push(DeleteShapesCommand(self, shapes))
def _show_shape_options(self, shape_id: ShapeID) -> None:
old_shape = self._icon.shapes.get(shape_id)
if old_shape is None:
return
dialog = ShapeOptionsDialog(old_shape, self.scene.sceneRect(), self)
if dialog.exec() == QDialog.DialogCode.Accepted:
new_shape = dialog.shape()
if new_shape != old_shape:
self.undo_stack.push(ChangeShapeCommand(self, shape_id, old_shape, new_shape))
def _ensure_port_positions(self, ports: dict[PortID, Port]) -> None:
bounds = self.scene.sceneRect()
left = round(bounds.left())
top = round(bounds.top())
right = round(bounds.right() - 16)
bottom = round(bounds.bottom() - 16)
positions: dict[PortID, tuple[int, int]] = {}
input_index = 0
output_index = 0
for port_id, port in ports.items():
if port.direction is SignalDirection.INPUT:
default = (left, top + input_index * 16)
input_index += 1
else:
default = (right, top + output_index * 16)
output_index += 1
position = self._icon.port_positions.get(port_id, default)
positions[port_id] = (max(left, min(right, position[0])), max(top, min(bottom, position[1])))
self._icon.port_positions = positions
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.scene.set_creation_tool(RectangleCreationTool(self.scene, layer))
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.scene.set_creation_tool(TextCreationTool(self.scene, layer))
def _tool_active_changed(self, active: bool) -> None:
if not active:
self.ui.actionAdd_Rectangle.setChecked(False)
self.ui.actionAdd_Text.setChecked(False)
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")

View File

@@ -0,0 +1,374 @@
from __future__ import annotations
import math
from copy import deepcopy
from typing import Protocol
from PySide6.QtCore import 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 bedit_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Icon, LineType, Rectangle, Shape, ShapeID, Text
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:
position = self._bounded(position)
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:
position = self._bounded(position)
self.preview.setRect(QRectF(self.start, position).normalized())
def finish(self, position: QPointF) -> Shape | None:
if self.start is None:
return None
position = self._bounded(position)
rect = QRectF(self.start, position).normalized()
self.cancel()
if rect.width() < 1 or rect.height() < 1:
return None
return self.create_shape(rect)
def create_shape(self, rect: QRectF) -> Shape:
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
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 TextCreationTool(RectangleCreationTool):
def create_shape(self, rect: QRectF) -> Shape:
return Text(layer=self.layer, pos=(round(rect.x()), round(rect.y())), width=rect.width(), height=rect.height(), text="Text")
class ShapeGraphicsItem(QGraphicsPathItem):
handle_size = 6.0
def __init__(self, shape_id: ShapeID, shape: Shape, scene: IconGraphicsScene) -> None:
super().__init__()
self.shape_id = shape_id
self.shape_model = deepcopy(shape)
self.icon_scene = scene
self._original_shape: Shape | None = None
self._resizing = False
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def resize_handle_rect(self) -> QRectF:
size = self.handle_size
corner = self.path().boundingRect().bottomRight()
return QRectF(corner.x() - size / 2, corner.y() - size / 2, size, size)
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:
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._resizing:
self.resize_to(event.scenePos())
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
if self._resizing:
self.resize_to(event.scenePos())
self._resizing = False
event.accept()
else:
super().mouseReleaseEvent(event)
current = self.current_shape()
if self._original_shape is not None and current != self._original_shape:
self.icon_scene.shape_changed.emit(self.shape_id, self._original_shape, current)
self._original_shape = None
def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None:
if not self.isSelected():
self.icon_scene.clearSelection()
self.setSelected(True)
menu = QMenu()
options = menu.addAction("Shape Options")
if menu.exec(event.screenPos()) is options:
self.icon_scene.shape_options_requested.emit(self.shape_id)
event.accept()
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None:
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())))
return QPointF(x, y)
return super().itemChange(change, value)
def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None:
super().paint(painter, option, widget)
if self.isSelected():
painter.setPen(QPen(QColor("#ffffff")))
painter.setBrush(QBrush(QColor("#2675bf")))
painter.drawRect(self.resize_handle_rect())
def current_shape(self) -> Shape:
raise NotImplementedError
def resize_to(self, position: QPointF) -> None:
raise NotImplementedError
class RectangleGraphicsItem(ShapeGraphicsItem):
def __init__(self, shape_id: ShapeID, shape: Rectangle, scene: IconGraphicsScene) -> None:
super().__init__(shape_id, shape, scene)
self.rectangle = deepcopy(shape)
self.setPos(shape.pos[0], shape.pos[1])
self._set_size(shape.width, shape.height)
self.setPen(scene._pen(shape))
self.setBrush(QBrush(scene._color(shape.fill_color)))
self.setZValue(shape.layer)
def current_shape(self) -> Rectangle:
shape = deepcopy(self.rectangle)
shape.pos = (round(self.pos().x()), round(self.pos().y()))
rect = self.path().boundingRect()
shape.width = rect.width()
shape.height = rect.height()
return shape
def resize_to(self, position: QPointF) -> 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())
self._set_size(width, height)
def _set_size(self, width: float, height: float) -> None:
self.prepareGeometryChange()
path = QPainterPath()
path.addRoundedRect(QRectF(0, 0, width, height), self.rectangle.corner_radius, self.rectangle.corner_radius)
self.setPath(path)
class TextGraphicsItem(ShapeGraphicsItem):
def __init__(self, shape_id: ShapeID, shape: Text, scene: IconGraphicsScene) -> None:
super().__init__(shape_id, shape, scene)
self.text = deepcopy(shape)
self.setPos(shape.pos[0], shape.pos[1])
self._set_size(shape.width, shape.height)
self.setPen(QPen(Qt.PenStyle.NoPen))
self.setBrush(QBrush(QColor(0, 0, 0, 0)))
self.setZValue(shape.layer)
def current_shape(self) -> Text:
shape = deepcopy(self.text)
shape.pos = (round(self.pos().x()), round(self.pos().y()))
rect = self.path().boundingRect()
shape.width = rect.width()
shape.height = rect.height()
return shape
def resize_to(self, position: QPointF) -> 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())
self._set_size(width, height)
def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget | None = None) -> None:
super().paint(painter, option, widget)
font = QFont()
font.setPixelSize(max(1, round(self.text.size)))
font.setBold(self.text.bold)
font.setItalic(self.text.italic)
painter.setFont(font)
painter.setPen(self.icon_scene._color(self.text.color))
painter.setClipRect(self.path().boundingRect())
painter.drawText(self.path().boundingRect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, self.text.text)
def _set_size(self, width: float, height: float) -> None:
self.prepareGeometryChange()
path = QPainterPath()
path.addRect(QRectF(0, 0, width, height))
self.setPath(path)
class PortGraphicsItem(QGraphicsRectItem):
size = 16.0
def __init__(self, port_id: PortID, port: Port, position: tuple[int, int], scene: IconGraphicsScene) -> None:
super().__init__(0, 0, self.size, self.size)
self.port_id = port_id
self.icon_scene = scene
self._original_position: tuple[int, int] | None = None
self.setPos(position[0], position[1])
self.setPen(QPen(QColor("#000000")))
self.setBrush(QBrush(QColor("#000000") if port.direction is SignalDirection.INPUT else QColor("#ffffff")))
self.setZValue(1000000)
self.setToolTip(port.name)
self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._original_position = self.position()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
super().mouseReleaseEvent(event)
position = self.position()
if self._original_position is not None and position != self._original_position:
self.icon_scene.port_moved.emit(self.port_id, self._original_position, position)
self._original_position = None
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change is QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene() is not None:
position = value
if isinstance(position, QPointF):
bounds = self.icon_scene.sceneRect()
x = max(bounds.left(), min(bounds.right() - self.size, round(position.x())))
y = max(bounds.top(), min(bounds.bottom() - self.size, round(position.y())))
return QPointF(x, y)
return super().itemChange(change, value)
def position(self) -> tuple[int, int]:
return (round(self.pos().x()), round(self.pos().y()))
class IconGraphicsScene(QGraphicsScene):
port_moved = Signal(object, object, object)
shape_created = Signal(object)
shape_changed = Signal(object, object, object)
shape_options_requested = Signal(object)
tool_active_changed = Signal(bool)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._tool: ShapeCreationTool | None = None
self._ports: dict[PortID, Port] = {}
self.setSceneRect(-64, -64, 128, 128)
def set_ports(self, ports: dict[PortID, Port]) -> None:
self._ports = deepcopy(ports)
def set_icon(self, icon: Icon) -> None:
self.cancel_creation_tool()
self.clear()
for shape_id, shape in sorted(icon.shapes.items(), key=lambda item: item[1].layer):
self._add_shape_item(shape_id, shape)
for port_id, port in self._ports.items():
position = icon.port_positions.get(port_id)
if position is not None:
self.addItem(PortGraphicsItem(port_id, port, position, self))
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 selected_shape_ids(self) -> list[ShapeID]:
return [item.shape_id for item in self.selectedItems() if isinstance(item, ShapeGraphicsItem)]
def drawBackground(self, painter: QPainter, rect: QRectF) -> None:
super().drawBackground(painter, rect)
rect = rect.intersected(self.sceneRect())
if rect.isEmpty():
return
pen = QPen(QColor("#d0d0d0"))
pen.setCosmetic(True)
painter.setPen(pen)
first_x = math.floor(rect.left() / 8) * 8
last_x = math.ceil(rect.right() / 8) * 8
first_y = math.floor(rect.top() / 8) * 8
last_y = math.ceil(rect.bottom() / 8) * 8
for x in range(first_x, last_x + 1, 8):
painter.drawLine(QPointF(x, rect.top()), QPointF(x, rect.bottom()))
for y in range(first_y, last_y + 1, 8):
painter.drawLine(QPointF(rect.left(), y), QPointF(rect.right(), y))
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_id: ShapeID, shape: Shape) -> None:
if isinstance(shape, Rectangle):
self.addItem(RectangleGraphicsItem(shape_id, shape, self))
elif isinstance(shape, Text):
self.addItem(TextGraphicsItem(shape_id, shape, self))
@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(IconGraphicsScene._color(shape.line_color), shape.line_thickness, styles[shape.line_type])
@staticmethod
def _color(value: str) -> QColor:
color = value.removeprefix("#")
if len(color) == 8:
return QColor(int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), int(color[6:8], 16))
return QColor(value)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
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.views.color_button import ColorButton
class ShapeOptionsDialog(QDialog):
def __init__(self, shape: Shape, scene_rect: QRectF, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._shape = deepcopy(shape)
self._scene_rect = scene_rect
self.setWindowTitle("Shape Options")
self.form = QFormLayout()
self.type_label = QLabel(shape.type or "")
self.layer = QSpinBox()
self.layer.setRange(-1000000, 1000000)
self.layer.setValue(shape.layer)
self.x = QSpinBox()
self.x.setRange(round(scene_rect.left()), round(scene_rect.right() - 1))
self.x.setValue(shape.pos[0])
self.y = QSpinBox()
self.y.setRange(round(scene_rect.top()), round(scene_rect.bottom() - 1))
self.y.setValue(shape.pos[1])
self.form.addRow("Type", self.type_label)
self.form.addRow("Layer", self.layer)
self.form.addRow("X", self.x)
self.form.addRow("Y", self.y)
if isinstance(shape, Rectangle):
self._add_rectangle_fields(shape)
elif isinstance(shape, Text):
self._add_text_fields(shape)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(self.form)
layout.addWidget(buttons)
def shape(self) -> Shape:
if isinstance(self._shape, Rectangle):
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 Rectangle(layer=self.layer.value(), pos=(self.x.value(), self.y.value()), width=width, height=height, line_type=self.line_type.currentData(), line_thickness=self.line_thickness.value(), corner_radius=self.corner_radius.value(), line_color=self.line_color.color(), fill_color=self.fill_color.color())
if isinstance(self._shape, Text):
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())
shape = deepcopy(self._shape)
shape.layer = self.layer.value()
shape.pos = (self.x.value(), self.y.value())
return shape
def _add_rectangle_fields(self, shape: Rectangle) -> None:
self.width = QSpinBox()
self.width.setRange(1, round(self._scene_rect.width()))
self.width.setValue(round(shape.width))
self.height = QSpinBox()
self.height.setRange(1, round(self._scene_rect.height()))
self.height.setValue(round(shape.height))
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.corner_radius = QDoubleSpinBox()
self.corner_radius.setRange(0, max(self._scene_rect.width(), self._scene_rect.height()))
self.corner_radius.setValue(shape.corner_radius)
self.line_color = ColorButton(shape.line_color)
self.fill_color = ColorButton(shape.fill_color)
self.form.addRow("Width", self.width)
self.form.addRow("Height", self.height)
self.form.addRow("Line type", self.line_type)
self.form.addRow("Line thickness", self.line_thickness)
self.form.addRow("Corner radius", self.corner_radius)
self.form.addRow("Line color", self.line_color)
self.form.addRow("Fill color", self.fill_color)
def _add_text_fields(self, shape: Text) -> None:
self.width = QSpinBox()
self.width.setRange(1, round(self._scene_rect.width()))
self.width.setValue(round(shape.width))
self.height = QSpinBox()
self.height.setRange(1, round(self._scene_rect.height()))
self.height.setValue(round(shape.height))
self.color = ColorButton(shape.color)
self.bold = QCheckBox()
self.bold.setChecked(shape.bold)
self.italic = QCheckBox()
self.italic.setChecked(shape.italic)
self.size = QDoubleSpinBox()
self.size.setRange(1, 1000)
self.size.setValue(shape.size)
self.text = QLineEdit(shape.text)
self.form.addRow("Width", self.width)
self.form.addRow("Height", self.height)
self.form.addRow("Color", self.color)
self.form.addRow("Bold", self.bold)
self.form.addRow("Italic", self.italic)
self.form.addRow("Size", self.size)
self.form.addRow("Text", self.text)

View File

@@ -396,5 +396,173 @@
} }
} }
}, },
"metadata": {} "metadata": {
"icon_database": {
"format_version": 1,
"icons": {
"43b3aee6-0b38-429b-9c6d-d38fc097297f": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64.0,
"height": 64.0,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "C"
}
},
"port_positions": {
"c87881f1-23b4-4a69-b586-8e3c7bf6e21e": [
-8,
-8
],
"c62de8eb-e13b-4849-bea5-c8cc5332269e": [
16,
-32
]
}
},
"5a8b2e8d-489f-467b-8282-7e344dfad576": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "R"
}
},
"port_positions": {
"0ac7d4ea-77f7-4c0d-8e37-406ef66e4740": [
-8,
-8
],
"d1f5aab2-7bff-4b1e-8697-98fb6c3d8f02": [
16,
-32
]
}
},
"77701f68-b14c-4b98-8929-c5fba0261962": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64.0,
"height": 64.0,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "R"
}
},
"port_positions": {
"4a3f69f2-b305-4305-92e3-3998634ff226": [
-8,
-8
]
}
},
"033b930e-bf79-403a-8b0b-a159f3c81ce9": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "I"
}
},
"port_positions": {
"0b9036b1-e4e4-437e-8c35-f5bb391b6cbd": [
-8,
-8
],
"fb25f35d-4c0c-4cfa-92d4-1a18a71e2c11": [
16,
-32
]
}
},
"9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "1"
}
},
"port_positions": {
"4306701a-6b1b-4d19-b8ff-45dbfa04f2d3": [
-8,
-8
]
}
},
"9f392c28-6bef-4b10-9305-93e250747007": {
"shapes": {
"2a5bf670-c821-442b-9d46-1b07faa70722": {
"layer": 0,
"type": "text",
"pos": [
-32,
-32
],
"width": 64,
"height": 64,
"color": "#000000ff",
"bold": true,
"italic": false,
"size": 64.0,
"text": "0"
}
},
"port_positions": {
"497b1f74-1186-471f-976a-36b07a451caf": [
-8,
-8
]
}
}
}
}
}
} }