Compare commits
4 Commits
713d094b08
...
c68e693359
| Author | SHA1 | Date | |
|---|---|---|---|
| c68e693359 | |||
| 13d3825f9b | |||
| 035cbdf53f | |||
| 09a9e3f12f |
5
.vscode/launch.json
vendored
5
.vscode/launch.json
vendored
@@ -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
25
0.icon.json
Normal 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
25
1.icon.json
Normal 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
29
C.icon.json
Normal 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
29
I.icon.json
Normal 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
29
R.icon.json
Normal 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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
document.new()
|
if args.file:
|
||||||
|
document.open(args.file)
|
||||||
|
else:
|
||||||
|
document.new()
|
||||||
|
|
||||||
window.showMaximized()
|
window.showMaximized()
|
||||||
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|||||||
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 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)
|
||||||
|
|||||||
@@ -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
106
src/bedit_gui/models.py
Normal 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()}}
|
||||||
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>
|
<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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
17
src/bedit_gui/services/icon_files.py
Normal file
17
src/bedit_gui/services/icon_files.py
Normal 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")
|
||||||
228
src/bedit_gui/ui/forms/icon_editor_window.ui
Normal file
228
src/bedit_gui/ui/forms/icon_editor_window.ui
Normal 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>
|
||||||
42
src/bedit_gui/views/color_button.py
Normal file
42
src/bedit_gui/views/color_button.py
Normal 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)
|
||||||
326
src/bedit_gui/views/icon_editor_window.py
Normal file
326
src/bedit_gui/views/icon_editor_window.py
Normal 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")
|
||||||
374
src/bedit_gui/views/icon_graphics_scene.py
Normal file
374
src/bedit_gui/views/icon_graphics_scene.py
Normal 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)
|
||||||
110
src/bedit_gui/views/shape_options_dialog.py
Normal file
110
src/bedit_gui/views/shape_options_dialog.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user