Compare commits

..

2 Commits

Author SHA1 Message Date
fcee2ac8b6 Moving items in the graph 2026-08-17 11:49:13 +02:00
8e7d2d3efd Graph view shows graph 2026-08-17 11:29:07 +02:00
16 changed files with 691 additions and 34 deletions

View File

@@ -46,7 +46,7 @@ def main() -> int:
settings = ApplicationSettings() settings = ApplicationSettings()
document = Document(app) document = Document(app)
window = MainWindow() window = MainWindow(settings.snap_to_grid_size)
window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}")) window.ui.actionAbout.triggered.connect(lambda: QMessageBox.about(window, "About BEdit", f"BEdit {BEDIT_VERSION}"))
window.ui.actionAbout_QT.triggered.connect(app.aboutQt) window.ui.actionAbout_QT.triggered.connect(app.aboutQt)

View File

@@ -0,0 +1,21 @@
from PySide6.QtGui import QUndoCommand
from bedit_core.models import ComponentID
class MoveGraphComponentCommand(QUndoCommand):
def __init__(self, document: object, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int]) -> None:
super().__init__("Move graph component")
self.document = document
self.graph_id = graph_id
self.component_id = component_id
database = document._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
self.old_position = graph.component_positions.get(component_id) if graph is not None else None
self.new_position = position
def redo(self) -> None:
self.document._set_graph_component_position(self.graph_id, self.component_id, self.new_position)
def undo(self) -> None:
self.document._set_graph_component_position(self.graph_id, self.component_id, self.old_position)

View File

@@ -9,13 +9,13 @@ from PySide6.QtWidgets import QAbstractItemView, QDialog, QHeaderView, QMenu
from bedit_core.models import Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID from bedit_core.models import Component, ComponentID, EquationImplementation, GraphImplementation, Port, PortID, Parameter, ParameterID
from bedit_core.models import Document as CoreDocument from bedit_core.models import Document as CoreDocument
from bedit_gui.documents import Document from bedit_gui.documents import Document
from bedit_gui.models import Icon from bedit_gui.models import Graph, Icon
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.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
from bedit_gui.utils.icon import render_icon from bedit_gui.utils.icon import render_fitted_icon
ICON_SIZE = QSize(32, 32) ICON_SIZE = QSize(32, 32)
@@ -62,10 +62,12 @@ class DocumentTreeController(QObject):
window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text) window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text)
document.model_changed.connect(self._on_document_changed) document.model_changed.connect(self._on_document_changed)
document.icon_changed.connect(self._on_icon_changed) document.icon_changed.connect(self._on_icon_changed)
document.graph_component_position_changed.connect(self._on_graph_component_position_changed)
document.equation_text_changed.connect(self._on_equation_text_changed) document.equation_text_changed.connect(self._on_equation_text_changed)
self.model.rename_document_requested.connect(self.document.rename) self.model.rename_document_requested.connect(self.document.rename)
self.model.rename_component_requested.connect(self.document.rename_component) self.model.rename_component_requested.connect(self.document.rename_component)
window.ui.actionDelete.triggered.connect(self.delete_selected_component) window.ui.actionDelete.triggered.connect(self.delete_selected_component)
window.graph_editor.component_move_requested.connect(self.document.move_graph_component)
# Add deselection with esc to this widget # Add deselection with esc to this widget
window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) window.ui.actionEscape.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
@@ -102,7 +104,7 @@ class DocumentTreeController(QObject):
self._collect_components(model.root) self._collect_components(model.root)
for component_id, component in self._components.items(): for component_id, component in self._components.items():
icon = self.document.component_icon(component_id) icon = self.document.component_icon(component_id)
self.model.set_component_icon(component_id, render_icon(icon, component.interface.ports, ICON_SIZE)) self.model.set_component_icon(component_id, render_fitted_icon(icon, component.interface.ports, ICON_SIZE))
# Optional presentation behavior. Later, you could instead remember # Optional presentation behavior. Later, you could instead remember
# expanded component IDs and restore only those nodes. # expanded component IDs and restore only those nodes.
@@ -121,7 +123,9 @@ class DocumentTreeController(QObject):
self.window.equation_editor.set_component(None) self.window.equation_editor.set_component(None)
self.window.equation_editor.hide() self.window.equation_editor.hide()
if component is not None and isinstance(component.implementation, GraphImplementation): if component is not None and isinstance(component.implementation, GraphImplementation):
self.window.graph_editor.set_component(component) graph = self.document.graph_database().graphs.get(self.document.component_id(component), Graph())
icons = {component_id: self.document.component_icon(component_id) for component_id in component.implementation.graph.components}
self.window.graph_editor.set_component(component, graph, icons)
self.window.graph_editor.show() self.window.graph_editor.show()
else: else:
self.window.graph_editor.set_component(None) self.window.graph_editor.set_component(None)
@@ -135,7 +139,15 @@ class DocumentTreeController(QObject):
component = self._components.get(component_id) component = self._components.get(component_id)
if component is None: if component is None:
return return
self.model.set_component_icon(component_id, render_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE)) self.model.set_component_icon(component_id, render_fitted_icon(icon if isinstance(icon, Icon) else Icon(), component.interface.ports, ICON_SIZE))
graph_component = self.window.graph_editor.component()
if graph_component is not None and isinstance(graph_component.implementation, GraphImplementation) and component_id in graph_component.implementation.graph.components:
self._show_component(graph_component)
def _on_graph_component_position_changed(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None:
graph_component = self.window.graph_editor.component()
if graph_component is not None and self.document.component_id(graph_component) == graph_id:
self.window.graph_editor.set_component_position(component_id, position)
def _collect_components(self, components: dict[ComponentID, Component]) -> None: def _collect_components(self, components: dict[ComponentID, Component]) -> None:
for component_id, component in components.items(): for component_id, component in components.items():

View File

@@ -18,10 +18,13 @@ class SettingsDialogLike(Protocol):
@property @property
def log_level(self) -> int: ... def log_level(self) -> int: ...
@property
def snap_to_grid_size(self) -> int: ...
def exec(self) -> int: ... def exec(self) -> int: ...
SettingsDialogFactory = Callable[[int, MainWindow], SettingsDialogLike] SettingsDialogFactory = Callable[[int, int, MainWindow], SettingsDialogLike]
class SettingsController(QObject): class SettingsController(QObject):
@@ -41,10 +44,12 @@ class SettingsController(QObject):
window.ui.actionSettings.triggered.connect(self.open_settings) window.ui.actionSettings.triggered.connect(self.open_settings)
def open_settings(self) -> None: def open_settings(self) -> None:
dialog = self.dialog_factory(self.settings.log_level, self.window) dialog = self.dialog_factory(self.settings.log_level, self.settings.snap_to_grid_size, self.window)
if dialog.exec() != QDialog.DialogCode.Accepted: if dialog.exec() != QDialog.DialogCode.Accepted:
return return
self.settings.log_level = dialog.log_level self.settings.log_level = dialog.log_level
self.settings.snap_to_grid_size = dialog.snap_to_grid_size
self.window.graph_editor.set_snap_to_grid_size(dialog.snap_to_grid_size)
set_log_level(dialog.log_level) set_log_level(dialog.log_level)
logger.info("Application settings updated") logger.info("Application settings updated")

View File

@@ -10,13 +10,14 @@ from bedit_core.models import ID, Component, ComponentID, GraphImplementation, P
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.change_icon_command import ChangeIconCommand
from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand from bedit_gui.commands.equation_text_command import ChangeEquationTextCommand
from bedit_gui.commands.graph_position_command import MoveGraphComponentCommand
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.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents
from bedit_gui.models import Icon, IconDatabase, Simulation, SimulationDatabase from bedit_gui.models import Graph, GraphDatabase, Icon, IconDatabase, Simulation, SimulationDatabase
from bedit_gui.services import document_files from bedit_gui.services import document_files
@@ -29,6 +30,7 @@ class Document(QObject):
icon_changed = Signal(object, object) icon_changed = Signal(object, object)
equation_text_changed = Signal(object, str) equation_text_changed = Signal(object, str)
simulation_database_changed = Signal(object) simulation_database_changed = Signal(object)
graph_component_position_changed = Signal(object, object, object)
def __init__(self, parent: QObject | None = None) -> None: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@@ -121,6 +123,46 @@ class Document(QObject):
def component_icon(self, component_id: ComponentID) -> Icon: def component_icon(self, component_id: ComponentID) -> Icon:
return self.stored_component_icon(component_id) or Icon() return self.stored_component_icon(component_id) or Icon()
def graph_database(self) -> GraphDatabase:
database = self._graph_database(False)
return deepcopy(database) if database is not None else GraphDatabase()
def move_graph_component(self, graph_component: Component, component_id: ComponentID, position: tuple[int, int]) -> None:
graph_id = self.component_id(graph_component)
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is None or graph.component_positions.get(component_id) != position:
self.undo_stack.push(MoveGraphComponentCommand(self, graph_id, component_id, position))
def _set_graph_component_position(self, graph_id: ComponentID, component_id: ComponentID, position: tuple[int, int] | None) -> None:
if position is None:
database = self._graph_database(False)
graph = database.graphs.get(graph_id) if database is not None else None
if graph is not None:
graph.component_positions.pop(component_id, None)
else:
database = self._graph_database(True)
graph = database.graphs.setdefault(graph_id, Graph())
graph.component_positions[component_id] = position
self.graph_component_position_changed.emit(graph_id, component_id, position)
def _graph_database(self, create: bool) -> GraphDatabase | None:
metadata = self.model.metadata
value = metadata.get("graph_database") if metadata is not None else None
if isinstance(value, dict):
value = GraphDatabase.from_data(value)
metadata["graph_database"] = value
if isinstance(value, GraphDatabase):
return value
if not create:
return None
if metadata is None:
metadata = {}
self.model.metadata = metadata
database = GraphDatabase()
metadata["graph_database"] = database
return database
def change_icon(self, component_id: ComponentID, icon: Icon) -> None: def change_icon(self, component_id: ComponentID, icon: Icon) -> None:
self.undo_stack.push(ChangeIconCommand(self, component_id, icon)) self.undo_stack.push(ChangeIconCommand(self, component_id, icon))

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from bedit_core.models import ComponentID, ID, PortID from bedit_core.models import ComponentID, ConnectionID, ID, PortID
class ShapeID(ID): class ShapeID(ID):
@@ -123,6 +123,50 @@ class IconDatabase:
def to_data(self) -> dict[str, Any]: 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()}} return {"format_version": self.format_version, "icons": {str(key): icon.to_data() for key, icon in self.icons.items()}}
@dataclass
class GraphConnection:
points: list[tuple[int, int]] = field(default_factory=list)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> GraphConnection:
return cls(points=[(int(point[0]), int(point[1])) for point in data.get("points", [])])
def to_data(self) -> dict[str, Any]:
return {"points": [list(point) for point in self.points]}
@dataclass
class Graph:
shapes: dict[ShapeID, Shape] = field(default_factory=dict)
component_positions: dict[ComponentID, tuple[int, int]] = field(default_factory=dict)
connections: dict[ConnectionID, GraphConnection] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> Graph:
shapes = {ShapeID(key): Shape.from_data(value) for key, value in data.get("shapes", {}).items()}
component_positions = {ComponentID(key): (int(value[0]), int(value[1])) for key, value in data.get("component_positions", {}).items()}
connections = {ConnectionID(key): GraphConnection.from_data(value) for key, value in data.get("connections", {}).items()}
return cls(shapes=shapes, component_positions=component_positions, connections=connections)
def to_data(self) -> dict[str, Any]:
return {
"shapes": {str(key): shape.to_data() for key, shape in self.shapes.items()},
"component_positions": {str(key): list(position) for key, position in self.component_positions.items()},
"connections": {str(key): connection.to_data() for key, connection in self.connections.items()},
}
@dataclass
class GraphDatabase:
format_version: int = 1
graphs: dict[ComponentID, Graph] = field(default_factory=dict)
@classmethod
def from_data(cls, data: Mapping[str, Any]) -> GraphDatabase:
graphs = {ComponentID(key): Graph.from_data(value) for key, value in data.get("graphs", {}).items()}
return cls(format_version=int(data.get("format_version", 1)), graphs=graphs)
def to_data(self) -> dict[str, Any]:
return {"format_version": self.format_version, "graphs": {str(key): graph.to_data() for key, graph in self.graphs.items()}}
class SimulationMethod(Enum): class SimulationMethod(Enum):
DASSL = "dassl" DASSL = "dassl"

View File

@@ -10,6 +10,8 @@ class ApplicationSettings:
LOG_LEVEL_KEY = "logging/level" LOG_LEVEL_KEY = "logging/level"
DEFAULT_LOG_LEVEL = logging.INFO DEFAULT_LOG_LEVEL = logging.INFO
SNAP_TO_GRID_SIZE_KEY = "graph/snap_to_grid_size"
DEFAULT_SNAP_TO_GRID_SIZE = 4
def __init__(self, settings: QSettings | None = None) -> None: def __init__(self, settings: QSettings | None = None) -> None:
self._settings = settings if settings is not None else QSettings() self._settings = settings if settings is not None else QSettings()
@@ -26,6 +28,16 @@ class ApplicationSettings:
def log_level(self, level: int) -> None: def log_level(self, level: int) -> None:
self._settings.setValue(self.LOG_LEVEL_KEY, level) self._settings.setValue(self.LOG_LEVEL_KEY, level)
@property
def snap_to_grid_size(self) -> int:
return max(1, self._settings.value(self.SNAP_TO_GRID_SIZE_KEY, self.DEFAULT_SNAP_TO_GRID_SIZE, type=int))
@snap_to_grid_size.setter
def snap_to_grid_size(self, size: int) -> None:
if size < 1:
raise ValueError("snap-to-grid size must be positive")
self._settings.setValue(self.SNAP_TO_GRID_SIZE_KEY, size)
class SimulationApplicationSettings: class SimulationApplicationSettings:
"""Typed access to persistent BEsim application settings.""" """Typed access to persistent BEsim application settings."""

View File

@@ -6,7 +6,7 @@ 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, SimulationDatabase from bedit_gui.models import GraphDatabase, IconDatabase, SimulationDatabase
def load(path: str | Path) -> Document: def load(path: str | Path) -> Document:
@@ -14,6 +14,8 @@ def load(path: str | Path) -> Document:
document = load_document(path) document = load_document(path)
if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict): 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"]) document.metadata["icon_database"] = IconDatabase.from_data(document.metadata["icon_database"])
if document.metadata is not None and isinstance(document.metadata.get("graph_database"), dict):
document.metadata["graph_database"] = GraphDatabase.from_data(document.metadata["graph_database"])
if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict): if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict):
document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"]) document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"])
return document return document
@@ -24,6 +26,8 @@ def save(document: Document, path: str | Path) -> None:
saved_document = deepcopy(document) saved_document = deepcopy(document)
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase): 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() saved_document.metadata["icon_database"] = saved_document.metadata["icon_database"].to_data()
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("graph_database"), GraphDatabase):
saved_document.metadata["graph_database"] = saved_document.metadata["graph_database"].to_data()
if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase): if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase):
saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data() saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data()
save_document(saved_document, path) save_document(saved_document, path)

View File

@@ -40,7 +40,7 @@
<property name="floatable"> <property name="floatable">
<bool>false</bool> <bool>false</bool>
</property> </property>
<addaction name="actionSelect"/> <addaction name="actionZoomToFit"/>
<addaction name="actionAddComponent"/> <addaction name="actionAddComponent"/>
<addaction name="actionAddConnection"/> <addaction name="actionAddConnection"/>
<addaction name="actionDelete"/> <addaction name="actionDelete"/>
@@ -51,21 +51,25 @@
<property name="frameShape"> <property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum> <enum>QFrame::Shape::NoFrame</enum>
</property> </property>
<property name="dragMode">
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
</property>
<property name="renderHints"> <property name="renderHints">
<set>QPainter::RenderHint::Antialiasing</set> <set>QPainter::RenderHint::Antialiasing</set>
</property> </property>
<property name="dragMode">
<enum>QGraphicsView::DragMode::RubberBandDrag</enum>
</property>
</widget> </widget>
</item> </item>
</layout> </layout>
<action name="actionSelect"> <action name="actionZoomToFit">
<property name="icon">
<iconset resource="../../resources/resources.qrc">
<normaloff>:/icons/icons/zoom-original.png</normaloff>:/icons/icons/zoom-original.png</iconset>
</property>
<property name="text"> <property name="text">
<string>Select</string> <string>Zoom to Fit</string>
</property> </property>
<property name="toolTip"> <property name="toolTip">
<string>Select graph items</string> <string>Zoom canvas to fit</string>
</property> </property>
</action> </action>
<action name="actionAddComponent"> <action name="actionAddComponent">
@@ -93,6 +97,8 @@
</property> </property>
</action> </action>
</widget> </widget>
<resources/> <resources>
<include location="../../resources/resources.qrc"/>
</resources>
<connections/> <connections/>
</ui> </ui>

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
################################################################################
## Form generated from reading UI file 'graph_editor_widget.ui'
##
## Created by: Qt User Interface Compiler version 6.11.1
##
## WARNING! All changes made in this file will be lost when recompiling UI file!
################################################################################
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
QMetaObject, QObject, QPoint, QRect,
QSize, QTime, QUrl, Qt)
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QCursor, QFont, QFontDatabase, QGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QFrame, QGraphicsView, QSizePolicy,
QToolBar, QVBoxLayout, QWidget)
import resources_rc
class Ui_graphEditorWidget(object):
def setupUi(self, graphEditorWidget):
if not graphEditorWidget.objectName():
graphEditorWidget.setObjectName(u"graphEditorWidget")
graphEditorWidget.resize(1079, 730)
self.actionZoomToFit = QAction(graphEditorWidget)
self.actionZoomToFit.setObjectName(u"actionZoomToFit")
icon = QIcon()
icon.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomToFit.setIcon(icon)
self.actionAddComponent = QAction(graphEditorWidget)
self.actionAddComponent.setObjectName(u"actionAddComponent")
self.actionAddConnection = QAction(graphEditorWidget)
self.actionAddConnection.setObjectName(u"actionAddConnection")
self.actionDelete = QAction(graphEditorWidget)
self.actionDelete.setObjectName(u"actionDelete")
self.verticalLayout = QVBoxLayout(graphEditorWidget)
self.verticalLayout.setSpacing(0)
self.verticalLayout.setObjectName(u"verticalLayout")
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.graphToolBar = QToolBar(graphEditorWidget)
self.graphToolBar.setObjectName(u"graphToolBar")
self.graphToolBar.setMovable(False)
self.graphToolBar.setFloatable(False)
self.verticalLayout.addWidget(self.graphToolBar)
self.graphicsView = QGraphicsView(graphEditorWidget)
self.graphicsView.setObjectName(u"graphicsView")
self.graphicsView.setFrameShape(QFrame.Shape.NoFrame)
self.graphicsView.setRenderHints(QPainter.RenderHint.Antialiasing)
self.graphicsView.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
self.verticalLayout.addWidget(self.graphicsView)
self.graphToolBar.addAction(self.actionZoomToFit)
self.graphToolBar.addAction(self.actionAddComponent)
self.graphToolBar.addAction(self.actionAddConnection)
self.graphToolBar.addAction(self.actionDelete)
self.retranslateUi(graphEditorWidget)
QMetaObject.connectSlotsByName(graphEditorWidget)
# setupUi
def retranslateUi(self, graphEditorWidget):
graphEditorWidget.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph Editor", None))
self.actionZoomToFit.setText(QCoreApplication.translate("graphEditorWidget", u"Zoom to Fit", None))
#if QT_CONFIG(tooltip)
self.actionZoomToFit.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Zoom canvas to fit", None))
#endif // QT_CONFIG(tooltip)
self.actionAddComponent.setText(QCoreApplication.translate("graphEditorWidget", u"Add Component", None))
#if QT_CONFIG(tooltip)
self.actionAddComponent.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a component", None))
#endif // QT_CONFIG(tooltip)
self.actionAddConnection.setText(QCoreApplication.translate("graphEditorWidget", u"Add Connection", None))
#if QT_CONFIG(tooltip)
self.actionAddConnection.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Add a connection", None))
#endif // QT_CONFIG(tooltip)
self.actionDelete.setText(QCoreApplication.translate("graphEditorWidget", u"Delete", None))
#if QT_CONFIG(tooltip)
self.actionDelete.setToolTip(QCoreApplication.translate("graphEditorWidget", u"Delete selected graph items", None))
#endif // QT_CONFIG(tooltip)
self.graphToolBar.setWindowTitle(QCoreApplication.translate("graphEditorWidget", u"Graph tools", None))
# retranslateUi

View File

@@ -55,7 +55,27 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="1" column="0">
<widget class="QLabel" name="labelSnapToGridSize">
<property name="text">
<string>Snap-to-grid size:</string>
</property>
</widget>
</item>
<item row="1" column="1"> <item row="1" column="1">
<widget class="QSpinBox" name="snapToGridSize">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>256</number>
</property>
<property name="value">
<number>4</number>
</property>
</widget>
</item>
<item row="2" column="1">
<spacer name="verticalSpacer"> <spacer name="verticalSpacer">
<property name="orientation"> <property name="orientation">
<enum>Qt::Orientation::Vertical</enum> <enum>Qt::Orientation::Vertical</enum>

View File

@@ -1,13 +1,15 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QRectF, QSize, Qt from PySide6.QtCore import QRectF, QSize, Qt
from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap from PySide6.QtGui import QBrush, QColor, QFont, QIcon, QPainter, QPen, QPixmap, QRegion
from bedit_core.models import Port, PortID, SignalDirection from bedit_core.models import Port, PortID, SignalDirection
from bedit_gui.models import Icon, Line, LineType, Rectangle, Text from bedit_gui.models import Icon, Line, LineType, Rectangle, Text
PORT_SIZE = 16 PORT_SIZE = 16
DEFAULT_ICON_SIZE = QSize(48, 48) DEFAULT_ICON_SIZE = QSize(48, 48)
ICON_MARGIN = 4
ICON_PREVIEW_OVERSAMPLE = 4
def get_bounding_box(icon: Icon) -> QRectF: def get_bounding_box(icon: Icon) -> QRectF:
@@ -35,6 +37,12 @@ def get_bounding_box(icon: Icon) -> QRectF:
return QRectF(left, top, right - left, bottom - top) return QRectF(left, top, right - left, bottom - top)
def get_pixmap_bounding_box(pixmap: QPixmap) -> QRectF:
"""Return the bounds of the pixels actually painted in a transparent pixmap."""
bounds = QRegion(pixmap.mask()).boundingRect()
return QRectF(bounds) if not bounds.isEmpty() else QRectF(0, 0, pixmap.width(), pixmap.height())
def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon: def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE, render_ports: bool = False) -> QIcon:
pixmap = QPixmap(size) pixmap = QPixmap(size)
pixmap.fill(Qt.GlobalColor.transparent) pixmap.fill(Qt.GlobalColor.transparent)
@@ -42,9 +50,7 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
if not icon.shapes and not icon.port_positions: if not icon.shapes and not icon.port_positions:
return QIcon(pixmap) return QIcon(pixmap)
available_width = max(1, size.width() - 4) scale = _render_scale(bounds, size)
available_height = max(1, size.height() - 4)
scale = min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
painter = QPainter(pixmap) painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(size.width() / 2, size.height() / 2) painter.translate(size.width() / 2, size.height() / 2)
@@ -82,6 +88,28 @@ def render_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICO
return QIcon(pixmap) return QIcon(pixmap)
def render_fitted_icon(icon: Icon, ports: dict[PortID, Port], size: QSize = DEFAULT_ICON_SIZE) -> QIcon:
"""Render an icon preview with its painted content fitted to a uniform size."""
render_size = QSize(size.width() * ICON_PREVIEW_OVERSAMPLE, size.height() * ICON_PREVIEW_OVERSAMPLE)
rendered = render_icon(icon, ports, render_size).pixmap(render_size)
bounds = get_pixmap_bounding_box(rendered).toAlignedRect()
content = rendered.copy(bounds)
available = QSize(max(1, size.width() - ICON_MARGIN), max(1, size.height() - ICON_MARGIN))
fitted = content.scaled(available, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
pixmap = QPixmap(size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.drawPixmap((size.width() - fitted.width()) // 2, (size.height() - fitted.height()) // 2, fitted)
painter.end()
return QIcon(pixmap)
def _render_scale(bounds: QRectF, size: QSize) -> float:
available_width = max(1, size.width() - ICON_MARGIN)
available_height = max(1, size.height() - ICON_MARGIN)
return min(available_width / max(1, bounds.width()), available_height / max(1, bounds.height()))
def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen: def _line_pen(line_type: LineType, thickness: float, color: str) -> QPen:
styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine} styles = {LineType.SOLID: Qt.PenStyle.SolidLine, LineType.DASHED: Qt.PenStyle.DashLine, LineType.DOTTED: Qt.PenStyle.DotLine, LineType.DASH_DOT: Qt.PenStyle.DashDotLine}
if line_type is LineType.NONE: if line_type is LineType.NONE:

View File

@@ -20,6 +20,7 @@ class SettingsDialog(QDialog):
def __init__( def __init__(
self, self,
log_level: int, log_level: int,
snap_to_grid_size: int,
parent: QWidget | None = None, parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
@@ -35,7 +36,12 @@ class SettingsDialog(QDialog):
self.ui.logLevel.setCurrentIndex( self.ui.logLevel.setCurrentIndex(
selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO) selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO)
) )
self.ui.snapToGridSize.setValue(snap_to_grid_size)
@property @property
def log_level(self) -> int: def log_level(self) -> int:
return int(self.ui.logLevel.currentData()) return int(self.ui.logLevel.currentData())
@property
def snap_to_grid_size(self) -> int:
return self.ui.snapToGridSize.value()

View File

@@ -1,17 +1,30 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import QEvent, QObject, QRectF, Qt from math import hypot
from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QWidget
from bedit_core.models import Component, GraphImplementation from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection
from bedit_gui.models import Graph, Icon
from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget
from bedit_gui.utils.icon import get_pixmap_bounding_box, render_icon
GRID_SPACING = 32 GRID_SPACING = 64
SCENE_SIZE = 10000 SCENE_SIZE = 10000
MIN_ZOOM = 0.2 MIN_ZOOM = 0.2
MAX_ZOOM = 4.0 MAX_ZOOM = 4.0
ZOOM_STEP = 1.15 ZOOM_STEP = 1.15
ZOOM_TO_FIT_PADDING = 32.0
COMPONENT_ICON_SIZE = QSize(128, 128)
FALLBACK_COMPONENT_SPACING = 128
CONNECTION_WIDTH = 4.0
CONNECTION_BOUNDING_BOX_SPACING = 16.0
CONNECTION_STRAIGHTEN_TOLERANCE = 8.0
ARROW_LENGTH = 32.0
ARROW_HALF_WIDTH = 16.0
CAUSALITY_TICK_HALF_LENGTH = 16.0
class GraphGraphicsScene(QGraphicsScene): class GraphGraphicsScene(QGraphicsScene):
@@ -22,40 +35,283 @@ class GraphGraphicsScene(QGraphicsScene):
pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine) pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine)
painter.setPen(pen) painter.setPen(pen)
scene_rect = self.sceneRect()
left = int(rect.left()) - int(rect.left()) % GRID_SPACING left = int(rect.left()) - int(rect.left()) % GRID_SPACING
top = int(rect.top()) - int(rect.top()) % GRID_SPACING top = int(rect.top()) - int(rect.top()) % GRID_SPACING
x = left x = left
while x <= rect.right(): while x <= rect.right():
painter.drawLine(x, rect.top(), x, rect.bottom()) painter.drawLine(x, scene_rect.top(), x, scene_rect.bottom())
x += GRID_SPACING x += GRID_SPACING
y = top y = top
while y <= rect.bottom(): while y <= rect.bottom():
painter.drawLine(rect.left(), y, rect.right(), y) painter.drawLine(scene_rect.left(), y, scene_rect.right(), y)
y += GRID_SPACING y += GRID_SPACING
class GraphConnectionItem(QGraphicsPathItem):
"""A routed connection with a full signal arrow or half bond arrow."""
def __init__(self, points: list[tuple[float, float]], *, half_arrow: bool, tick_at_source: bool | None = None) -> None:
super().__init__()
self.setPath(self._connection_path(points, half_arrow, tick_at_source))
pen = QPen(QColor("#202020"), CONNECTION_WIDTH)
pen.setCosmetic(True)
self.setPen(pen)
self.setZValue(-1)
@staticmethod
def _connection_path(points: list[tuple[float, float]], half_arrow: bool, tick_at_source: bool | None = None) -> QPainterPath:
path = QPainterPath(QPointF(*points[0]))
for point in points[1:]:
path.lineTo(QPointF(*point))
target = QPointF(*points[-1])
previous = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != target), None)
if previous is None:
return path
dx = target.x() - previous.x()
dy = target.y() - previous.y()
length = hypot(dx, dy)
back_x = target.x() - ARROW_LENGTH * dx / length
back_y = target.y() - ARROW_LENGTH * dy / length
perpendicular_x = -ARROW_HALF_WIDTH * dy / length
perpendicular_y = ARROW_HALF_WIDTH * dx / length
path.moveTo(target)
path.lineTo(back_x + perpendicular_x, back_y + perpendicular_y)
if not half_arrow:
path.moveTo(target)
path.lineTo(back_x - perpendicular_x, back_y - perpendicular_y)
if tick_at_source is not None:
GraphConnectionItem._add_causality_tick(path, points, tick_at_source)
return path
@staticmethod
def _add_causality_tick(path: QPainterPath, points: list[tuple[float, float]], at_source: bool) -> None:
if at_source:
endpoint = QPointF(*points[0])
neighbor = next((QPointF(*point) for point in points[1:] if QPointF(*point) != endpoint), None)
else:
endpoint = QPointF(*points[-1])
neighbor = next((QPointF(*point) for point in reversed(points[:-1]) if QPointF(*point) != endpoint), None)
if neighbor is None:
return
dx = neighbor.x() - endpoint.x()
dy = neighbor.y() - endpoint.y()
length = hypot(dx, dy)
perpendicular_x = -CAUSALITY_TICK_HALF_LENGTH * dy / length
perpendicular_y = CAUSALITY_TICK_HALF_LENGTH * dx / length
path.moveTo(endpoint.x() - perpendicular_x, endpoint.y() - perpendicular_y)
path.lineTo(endpoint.x() + perpendicular_x, endpoint.y() + perpendicular_y)
class GraphComponentItem(QGraphicsPixmapItem):
def __init__(self, component_id: ComponentID, pixmap, editor: GraphEditorWidget) -> None:
super().__init__(pixmap)
self.component_id = component_id
self.editor = editor
self._drag_start = QPointF()
self._dragging = False
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: object) -> object:
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self._dragging and isinstance(value, QPointF):
grid_size = self.editor.snap_to_grid_size
value = QPointF(round(value.x() / grid_size) * grid_size, round(value.y() / grid_size) * grid_size)
result = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
self.editor.refresh_connections()
return result
def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None:
self._drag_start = QPointF(self.pos())
self._dragging = True
super().mousePressEvent(event)
def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None:
super().mouseReleaseEvent(event)
self._dragging = False
position = (round(self.pos().x()), round(self.pos().y()))
self.setPos(*position)
if self.pos() != self._drag_start:
self.editor.finish_component_move(self.component_id, position)
class GraphEditorWidget(QWidget): class GraphEditorWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None: component_move_requested = Signal(object, object, object)
def __init__(self, parent: QWidget | None = None, snap_to_grid_size: int = 4) -> None:
super().__init__(parent) super().__init__(parent)
self.ui = Ui_graphEditorWidget() self.ui = Ui_graphEditorWidget()
self.ui.setupUi(self) self.ui.setupUi(self)
self._component: Component | None = None self._component: Component | None = None
self._graph = Graph()
self._component_items: dict[ComponentID, GraphComponentItem] = {}
self._component_bounds: dict[ComponentID, QRectF] = {}
self._connection_items: dict[ConnectionID, GraphConnectionItem] = {}
self.set_snap_to_grid_size(snap_to_grid_size)
self.scene = GraphGraphicsScene(self) self.scene = GraphGraphicsScene(self)
self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE) self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
self.ui.graphicsView.setScene(self.scene) self.ui.graphicsView.setScene(self.scene)
self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.ui.graphicsView.viewport().installEventFilter(self) self.ui.graphicsView.viewport().installEventFilter(self)
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
self.ui.graphicsView.centerOn(0, 0) self.ui.graphicsView.centerOn(0, 0)
def set_component(self, component: Component | None) -> None: @property
def snap_to_grid_size(self) -> int:
return self._snap_to_grid_size
def set_snap_to_grid_size(self, size: int) -> None:
if size < 1:
raise ValueError("snap-to-grid size must be positive")
self._snap_to_grid_size = size
def set_component(self, component: Component | None, graph: Graph | None = None, icons: dict[ComponentID, Icon] | None = None) -> None:
if component is not None and not isinstance(component.implementation, GraphImplementation): if component is not None and not isinstance(component.implementation, GraphImplementation):
raise TypeError("GraphEditorWidget only supports components with a graph implementation") raise TypeError("GraphEditorWidget only supports components with a graph implementation")
component_changed = component is not self._component
self._component = component self._component = component
self._graph = graph or Graph()
self._component_items = {}
self._component_bounds = {}
self._connection_items = {}
self.scene.clear()
if component is None:
return
graph = self._graph
icons = icons or {}
positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
for component_id, child in component.implementation.graph.components.items():
icon = icons.get(component_id, Icon())
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE)
item = GraphComponentItem(component_id, pixmap, self)
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
item.setPos(*positions[component_id])
item.setToolTip(child.name)
bounds = get_pixmap_bounding_box(pixmap)
self._component_items[component_id] = item
self._component_bounds[component_id] = bounds.translated(-pixmap.width() / 2, -pixmap.height() / 2)
self.scene.addItem(item)
for connection_id, connection in component.implementation.graph.connections.items():
if not isinstance(connection, (SignalConnection, BondConnection)):
continue
tick_at_source = None
if isinstance(connection, BondConnection):
if connection.causality is BondCausality.EFFORT_OUT:
tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True
connection_item = GraphConnectionItem([(0, 0), (1, 0)], half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source)
connection_item.setData(0, str(connection_id))
self._connection_items[connection_id] = connection_item
self.scene.addItem(connection_item)
self.refresh_connections()
if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger)
def refresh_connections(self) -> None:
component = self._component
if component is None or not self._connection_items:
return
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
for connection_id, item in self._connection_items.items():
connection = component.implementation.graph.connections[connection_id]
source_component = port_owners.get(connection.source)
target_component = port_owners.get(connection.target)
if source_component is None or target_component is None:
continue
source_position = self._item_position(source_component)
target_position = self._item_position(target_component)
visual_connection = self._graph.connections.get(connection_id)
points = list(visual_connection.points) if visual_connection is not None else []
points = [source_position, target_position] if len(points) < 2 else [source_position, *points[1:-1], target_position]
points = self._straighten_direct_connection(points)
source_bounds = self._component_bounds[source_component].translated(*source_position)
target_bounds = self._component_bounds[target_component].translated(*target_position)
points = self._clip_connection(points, source_bounds, target_bounds)
tick_at_source = None
if isinstance(connection, BondConnection):
tick_at_source = False if connection.causality is BondCausality.EFFORT_OUT else True if connection.causality is BondCausality.FLOW_OUT else None
item.setPath(item._connection_path(points, isinstance(connection, BondConnection), tick_at_source))
def finish_component_move(self, component_id: ComponentID, position: tuple[int, int]) -> None:
if self._component is not None:
self.component_move_requested.emit(self._component, component_id, position)
def set_component_position(self, component_id: ComponentID, position: tuple[int, int] | None) -> None:
item = self._component_items.get(component_id)
if item is None:
return
if position is None:
index = list(self._component_items).index(component_id)
position = (index * FALLBACK_COMPONENT_SPACING, 0)
item.setPos(*position)
self.refresh_connections()
def _item_position(self, component_id: ComponentID) -> tuple[float, float]:
position = self._component_items[component_id].pos()
return position.x(), position.y()
@staticmethod
def _straighten_direct_connection(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
if len(points) != 2:
return points
source, target = points
dx = target[0] - source[0]
dy = target[1] - source[1]
if abs(dx) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dx) < abs(dy):
x = (source[0] + target[0]) / 2
return [(x, source[1]), (x, target[1])]
if abs(dy) <= CONNECTION_STRAIGHTEN_TOLERANCE and abs(dy) < abs(dx):
y = (source[1] + target[1]) / 2
return [(source[0], y), (target[0], y)]
return points
@staticmethod
def _clip_connection(points: list[tuple[float, float]], source_bounds: QRectF, target_bounds: QRectF) -> list[tuple[float, float]]:
source = points[0]
target = points[-1]
source_direction = next((point for point in points[1:] if point != source), None)
target_direction = next((point for point in reversed(points[:-1]) if point != target), None)
if source_direction is None or target_direction is None:
return points
clipped = list(points)
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source, source_direction)
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target, target_direction)
return clipped
@staticmethod
def _bounding_box_edge(bounds: QRectF, origin: tuple[float, float], toward: tuple[float, float]) -> tuple[float, float]:
bounds = bounds.adjusted(-CONNECTION_BOUNDING_BOX_SPACING, -CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING)
dx = toward[0] - origin[0]
dy = toward[1] - origin[1]
horizontal_scale = (bounds.right() - origin[0]) / dx if dx > 0 else (bounds.left() - origin[0]) / dx if dx < 0 else float("inf")
vertical_scale = (bounds.bottom() - origin[1]) / dy if dy > 0 else (bounds.top() - origin[1]) / dy if dy < 0 else float("inf")
scale = min(horizontal_scale, vertical_scale)
return origin[0] + dx * scale, origin[1] + dy * scale
def component(self) -> Component | None: def component(self) -> Component | None:
return self._component return self._component
def zoom_to_fit(self) -> None:
bounds = self.scene.itemsBoundingRect()
if bounds.isEmpty():
self.ui.graphicsView.resetTransform()
self.ui.graphicsView.centerOn(0, 0)
return
bounds.adjust(-ZOOM_TO_FIT_PADDING, -ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING, ZOOM_TO_FIT_PADDING)
self.ui.graphicsView.fitInView(bounds, Qt.AspectRatioMode.KeepAspectRatio)
current_zoom = self.ui.graphicsView.transform().m11()
target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, current_zoom))
if target_zoom != current_zoom:
self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom)
self.ui.graphicsView.centerOn(bounds.center())
def eventFilter(self, watched: QObject, event: QEvent) -> bool: def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel: if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel:
assert isinstance(event, QWheelEvent) assert isinstance(event, QWheelEvent)

View File

@@ -7,7 +7,7 @@ from bedit_gui.views.graph_editor_widget import GraphEditorWidget
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self) -> None: def __init__(self, snap_to_grid_size: int = 4) -> None:
super().__init__() super().__init__()
self.ui = Ui_MainWindow() self.ui = Ui_MainWindow()
@@ -18,7 +18,7 @@ class MainWindow(QMainWindow):
self.equation_editor = EquationEditorWidget(self.ui.centralwidget) self.equation_editor = EquationEditorWidget(self.ui.centralwidget)
self.equation_editor.hide() self.equation_editor.hide()
central_layout.addWidget(self.equation_editor) central_layout.addWidget(self.equation_editor)
self.graph_editor = GraphEditorWidget(self.ui.centralwidget) self.graph_editor = GraphEditorWidget(self.ui.centralwidget, snap_to_grid_size)
self.graph_editor.hide() self.graph_editor.hide()
central_layout.addWidget(self.graph_editor) central_layout.addWidget(self.graph_editor)

View File

@@ -589,6 +589,118 @@
} }
} }
}, },
"graph_database": {
"format_version": 1,
"graphs": {
"50e6ef97-f686-4400-bc01-e5a352e8cc22": {
"shapes": {},
"component_positions": {
"43b3aee6-0b38-429b-9c6d-d38fc097297f": [
64,
-384
],
"9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": [
64,
-128
],
"5a8b2e8d-489f-467b-8282-7e344dfad576": [
320,
-128
],
"2804f2f1-6123-4a53-aff1-87a3a8202711": [
-192,
-128
],
"9f392c28-6bef-4b10-9305-93e250747007": [
64,
128
],
"77701f68-b14c-4b98-8929-c5fba0261962": [
320,
128
],
"033b930e-bf79-403a-8b0b-a159f3c81ce9": [
-192,
128
]
},
"connections": {
"7e293c73-1e07-4d4c-b750-6f43870c71c4": {
"points": [
[
-320,
-128
],
[
-128,
-128
]
]
},
"66414b62-ca76-4be6-8da4-72551f8ffc0e": {
"points": [
[
128,
160
],
[
352,
96
]
]
},
"8aaa96b3-45ed-4674-8f7a-06b28178a5c8": {
"points": [
[
-128,
-128
],
[
128,
-256
]
]
},
"ff606583-d31f-4638-93f7-926163db2665": {
"points": [
[
-128,
-128
],
[
128,
-64
]
]
},
"1f52dc5a-1847-4982-aabc-2538cf99a86f": {
"points": [
[
128,
160
],
[
352,
224
]
]
},
"5af42b61-2317-48a8-b32b-0e2dfd88368e": {
"points": [
[
-128,
-128
],
[
128,
160
]
]
}
}
}
}
},
"simulation_database": { "simulation_database": {
"format_version": 1, "format_version": 1,
"active_simulation": "7778ca68-7a36-401b-b4aa-236a44c5e771", "active_simulation": "7778ca68-7a36-401b-b4aa-236a44c5e771",