Moving items in the graph

This commit is contained in:
2026-08-17 11:49:13 +02:00
parent 8e7d2d3efd
commit fcee2ac8b6
11 changed files with 270 additions and 68 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

@@ -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)
@@ -142,6 +144,11 @@ class DocumentTreeController(QObject):
if graph_component is not None and isinstance(graph_component.implementation, GraphImplementation) and component_id in graph_component.implementation.graph.components: 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) 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():
self._components[component_id] = component self._components[component_id] = component

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 GraphDatabase, 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)
@@ -122,12 +124,44 @@ class Document(QObject):
return self.stored_component_icon(component_id) or Icon() return self.stored_component_icon(component_id) or Icon()
def graph_database(self) -> GraphDatabase: 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 metadata = self.model.metadata
value = metadata.get("graph_database") if metadata is not None else None value = metadata.get("graph_database") if metadata is not None else None
if isinstance(value, dict): if isinstance(value, dict):
value = GraphDatabase.from_data(value) value = GraphDatabase.from_data(value)
metadata["graph_database"] = value metadata["graph_database"] = value
return deepcopy(value) if isinstance(value, GraphDatabase) else GraphDatabase() 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

@@ -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

@@ -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

@@ -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

@@ -2,28 +2,29 @@ from __future__ import annotations
from math import hypot from math import hypot
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, QSize, QTimer, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QWheelEvent
from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsView, QWidget from PySide6.QtWidgets import QGraphicsItem, QGraphicsPathItem, QGraphicsPixmapItem, QGraphicsScene, QGraphicsSceneMouseEvent, QGraphicsView, QWidget
from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, GraphImplementation, SignalConnection from bedit_core.models import BondCausality, BondConnection, Component, ComponentID, ConnectionID, GraphImplementation, SignalConnection
from bedit_gui.models import Graph, Icon 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 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 ZOOM_TO_FIT_PADDING = 32.0
COMPONENT_ICON_SIZE = QSize(96, 96) COMPONENT_ICON_SIZE = QSize(128, 128)
FALLBACK_COMPONENT_SPACING = 128 FALLBACK_COMPONENT_SPACING = 128
CONNECTION_WIDTH = 2.0 CONNECTION_WIDTH = 4.0
CONNECTION_BOUNDING_BOX_SPACING = 8.0 CONNECTION_BOUNDING_BOX_SPACING = 16.0
ARROW_LENGTH = 14.0 CONNECTION_STRAIGHTEN_TOLERANCE = 8.0
ARROW_HALF_WIDTH = 7.0 ARROW_LENGTH = 32.0
CAUSALITY_TICK_HALF_LENGTH = 8.0 ARROW_HALF_WIDTH = 16.0
CAUSALITY_TICK_HALF_LENGTH = 16.0
class GraphGraphicsScene(QGraphicsScene): class GraphGraphicsScene(QGraphicsScene):
@@ -34,15 +35,16 @@ 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
@@ -102,13 +104,54 @@ class GraphConnectionItem(QGraphicsPathItem):
path.lineTo(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)
@@ -117,65 +160,120 @@ class GraphEditorWidget(QWidget):
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit) self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
self.ui.graphicsView.centerOn(0, 0) self.ui.graphicsView.centerOn(0, 0)
@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: 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 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() self.scene.clear()
if component is None: if component is None:
return return
graph = graph or Graph() graph = self._graph
icons = icons or {} 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)} positions = {component_id: graph.component_positions.get(component_id, (index * FALLBACK_COMPONENT_SPACING, 0)) for index, component_id in enumerate(component.implementation.graph.components)}
port_owners = {port_id: component_id for component_id, child in component.implementation.graph.components.items() for port_id in child.interface.ports}
component_items: dict[ComponentID, QGraphicsPixmapItem] = {}
component_bounds: dict[ComponentID, QRectF] = {}
for component_id, child in component.implementation.graph.components.items(): for component_id, child in component.implementation.graph.components.items():
icon = icons.get(component_id, Icon()) icon = icons.get(component_id, Icon())
pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE) pixmap = render_icon(icon, child.interface.ports, COMPONENT_ICON_SIZE).pixmap(COMPONENT_ICON_SIZE)
item = QGraphicsPixmapItem(pixmap) item = GraphComponentItem(component_id, pixmap, self)
item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2) item.setOffset(-pixmap.width() / 2, -pixmap.height() / 2)
item.setPos(*positions[component_id]) item.setPos(*positions[component_id])
item.setToolTip(child.name) item.setToolTip(child.name)
item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
component_items[component_id] = item
bounds = get_pixmap_bounding_box(pixmap) bounds = get_pixmap_bounding_box(pixmap)
component_bounds[component_id] = bounds.translated(item.pos().x() - pixmap.width() / 2, item.pos().y() - pixmap.height() / 2) 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(): for connection_id, connection in component.implementation.graph.connections.items():
if not isinstance(connection, (SignalConnection, BondConnection)): if not isinstance(connection, (SignalConnection, BondConnection)):
continue continue
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
visual_connection = graph.connections.get(connection_id)
points = list(visual_connection.points) if visual_connection is not None else []
if len(points) < 2:
points = [positions[source_component], positions[target_component]]
else:
points = [positions[source_component], *points[1:-1], positions[target_component]]
points = self._clip_connection(points, component_bounds[source_component], component_bounds[target_component])
tick_at_source = None tick_at_source = None
if isinstance(connection, BondConnection): if isinstance(connection, BondConnection):
if connection.causality is BondCausality.EFFORT_OUT: if connection.causality is BondCausality.EFFORT_OUT:
tick_at_source = False tick_at_source = False
elif connection.causality is BondCausality.FLOW_OUT: elif connection.causality is BondCausality.FLOW_OUT:
tick_at_source = True tick_at_source = True
connection_item = GraphConnectionItem(points, half_arrow=isinstance(connection, BondConnection), tick_at_source=tick_at_source) 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)) connection_item.setData(0, str(connection_id))
self._connection_items[connection_id] = connection_item
self.scene.addItem(connection_item) self.scene.addItem(connection_item)
self.refresh_connections()
for item in component_items.values():
self.scene.addItem(item)
if component_changed: if component_changed:
QTimer.singleShot(0, self.ui.actionZoomToFit.trigger) 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 @staticmethod
def _clip_connection(points: list[tuple[int, int]], source_bounds: QRectF, target_bounds: QRectF) -> list[tuple[float, float]]: 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] source = points[0]
target = points[-1] target = points[-1]
source_direction = next((point for point in points[1:] if point != source), None) source_direction = next((point for point in points[1:] if point != source), None)
@@ -183,20 +281,19 @@ class GraphEditorWidget(QWidget):
if source_direction is None or target_direction is None: if source_direction is None or target_direction is None:
return points return points
clipped = list(points) clipped = list(points)
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source_direction) clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source, source_direction)
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target_direction) clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target, target_direction)
return clipped return clipped
@staticmethod @staticmethod
def _bounding_box_edge(bounds: QRectF, toward: tuple[int, int]) -> tuple[float, float]: 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) bounds = bounds.adjusted(-CONNECTION_BOUNDING_BOX_SPACING, -CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING, CONNECTION_BOUNDING_BOX_SPACING)
center = bounds.center() dx = toward[0] - origin[0]
dx = toward[0] - center.x() dy = toward[1] - origin[1]
dy = toward[1] - center.y() horizontal_scale = (bounds.right() - origin[0]) / dx if dx > 0 else (bounds.left() - origin[0]) / dx if dx < 0 else float("inf")
horizontal_scale = bounds.width() / 2 / abs(dx) if dx 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")
vertical_scale = bounds.height() / 2 / abs(dy) if dy else float("inf")
scale = min(horizontal_scale, vertical_scale) scale = min(horizontal_scale, vertical_scale)
return center.x() + dx * scale, center.y() + dy * 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

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

@@ -438,8 +438,8 @@
-32, -32,
-32 -32
], ],
"width": 64, "width": 64.0,
"height": 64, "height": 64.0,
"color": "#000000ff", "color": "#000000ff",
"bold": true, "bold": true,
"italic": false, "italic": false,
@@ -596,32 +596,32 @@
"shapes": {}, "shapes": {},
"component_positions": { "component_positions": {
"43b3aee6-0b38-429b-9c6d-d38fc097297f": [ "43b3aee6-0b38-429b-9c6d-d38fc097297f": [
128, 64,
-256 -384
], ],
"9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": [ "9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": [
-128, 64,
-128 -128
], ],
"5a8b2e8d-489f-467b-8282-7e344dfad576": [ "5a8b2e8d-489f-467b-8282-7e344dfad576": [
128, 320,
-64 -128
], ],
"2804f2f1-6123-4a53-aff1-87a3a8202711": [ "2804f2f1-6123-4a53-aff1-87a3a8202711": [
-320, -192,
-128 -128
], ],
"9f392c28-6bef-4b10-9305-93e250747007": [ "9f392c28-6bef-4b10-9305-93e250747007": [
128, 64,
160 128
], ],
"77701f68-b14c-4b98-8929-c5fba0261962": [ "77701f68-b14c-4b98-8929-c5fba0261962": [
352, 320,
96 128
], ],
"033b930e-bf79-403a-8b0b-a159f3c81ce9": [ "033b930e-bf79-403a-8b0b-a159f3c81ce9": [
352, -192,
224 128
] ]
}, },
"connections": { "connections": {