Moving items in the graph
This commit is contained in:
@@ -46,7 +46,7 @@ def main() -> int:
|
||||
|
||||
settings = ApplicationSettings()
|
||||
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_QT.triggered.connect(app.aboutQt)
|
||||
|
||||
|
||||
21
src/bedit_gui/commands/graph_position_command.py
Normal file
21
src/bedit_gui/commands/graph_position_command.py
Normal 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)
|
||||
@@ -62,10 +62,12 @@ class DocumentTreeController(QObject):
|
||||
window.equation_editor.equation_text_change_requested.connect(self.document.update_component_equation_text)
|
||||
document.model_changed.connect(self._on_document_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)
|
||||
self.model.rename_document_requested.connect(self.document.rename)
|
||||
self.model.rename_component_requested.connect(self.document.rename_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
|
||||
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:
|
||||
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:
|
||||
for component_id, component in components.items():
|
||||
self._components[component_id] = component
|
||||
|
||||
@@ -18,10 +18,13 @@ class SettingsDialogLike(Protocol):
|
||||
@property
|
||||
def log_level(self) -> int: ...
|
||||
|
||||
@property
|
||||
def snap_to_grid_size(self) -> int: ...
|
||||
|
||||
def exec(self) -> int: ...
|
||||
|
||||
|
||||
SettingsDialogFactory = Callable[[int, MainWindow], SettingsDialogLike]
|
||||
SettingsDialogFactory = Callable[[int, int, MainWindow], SettingsDialogLike]
|
||||
|
||||
|
||||
class SettingsController(QObject):
|
||||
@@ -41,10 +44,12 @@ class SettingsController(QObject):
|
||||
window.ui.actionSettings.triggered.connect(self.open_settings)
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
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)
|
||||
logger.info("Application settings updated")
|
||||
|
||||
@@ -10,13 +10,14 @@ from bedit_core.models import ID, Component, ComponentID, GraphImplementation, P
|
||||
from bedit_core.models import Document as CoreDocument
|
||||
from bedit_gui.commands.change_icon_command import ChangeIconCommand
|
||||
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.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand
|
||||
from bedit_gui.commands.rename_component_command import RenameComponentCommand
|
||||
from bedit_gui.commands.rename_document_command import RenameDocumentCommand
|
||||
from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand
|
||||
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
|
||||
|
||||
|
||||
@@ -29,6 +30,7 @@ class Document(QObject):
|
||||
icon_changed = Signal(object, object)
|
||||
equation_text_changed = Signal(object, str)
|
||||
simulation_database_changed = Signal(object)
|
||||
graph_component_position_changed = Signal(object, object, object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -122,12 +124,44 @@ class Document(QObject):
|
||||
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
|
||||
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:
|
||||
self.undo_stack.push(ChangeIconCommand(self, component_id, icon))
|
||||
|
||||
@@ -10,6 +10,8 @@ class ApplicationSettings:
|
||||
|
||||
LOG_LEVEL_KEY = "logging/level"
|
||||
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:
|
||||
self._settings = settings if settings is not None else QSettings()
|
||||
@@ -26,6 +28,16 @@ class ApplicationSettings:
|
||||
def log_level(self, level: int) -> None:
|
||||
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:
|
||||
"""Typed access to persistent BEsim application settings."""
|
||||
|
||||
|
||||
@@ -55,7 +55,27 @@
|
||||
</property>
|
||||
</widget>
|
||||
</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">
|
||||
<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">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
|
||||
@@ -20,6 +20,7 @@ class SettingsDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
log_level: int,
|
||||
snap_to_grid_size: int,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -35,7 +36,12 @@ class SettingsDialog(QDialog):
|
||||
self.ui.logLevel.setCurrentIndex(
|
||||
selected if selected >= 0 else self.LOG_LEVELS.index(logging.INFO)
|
||||
)
|
||||
self.ui.snapToGridSize.setValue(snap_to_grid_size)
|
||||
|
||||
@property
|
||||
def log_level(self) -> int:
|
||||
return int(self.ui.logLevel.currentData())
|
||||
|
||||
@property
|
||||
def snap_to_grid_size(self) -> int:
|
||||
return self.ui.snapToGridSize.value()
|
||||
|
||||
@@ -2,28 +2,29 @@ from __future__ import annotations
|
||||
|
||||
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.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.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
|
||||
MIN_ZOOM = 0.2
|
||||
MAX_ZOOM = 4.0
|
||||
ZOOM_STEP = 1.15
|
||||
ZOOM_TO_FIT_PADDING = 32.0
|
||||
COMPONENT_ICON_SIZE = QSize(96, 96)
|
||||
COMPONENT_ICON_SIZE = QSize(128, 128)
|
||||
FALLBACK_COMPONENT_SPACING = 128
|
||||
CONNECTION_WIDTH = 2.0
|
||||
CONNECTION_BOUNDING_BOX_SPACING = 8.0
|
||||
ARROW_LENGTH = 14.0
|
||||
ARROW_HALF_WIDTH = 7.0
|
||||
CAUSALITY_TICK_HALF_LENGTH = 8.0
|
||||
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):
|
||||
@@ -34,15 +35,16 @@ class GraphGraphicsScene(QGraphicsScene):
|
||||
pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine)
|
||||
painter.setPen(pen)
|
||||
|
||||
scene_rect = self.sceneRect()
|
||||
left = int(rect.left()) - int(rect.left()) % GRID_SPACING
|
||||
top = int(rect.top()) - int(rect.top()) % GRID_SPACING
|
||||
x = left
|
||||
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
|
||||
y = top
|
||||
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
|
||||
|
||||
|
||||
@@ -102,13 +104,54 @@ class GraphConnectionItem(QGraphicsPathItem):
|
||||
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):
|
||||
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)
|
||||
|
||||
self.ui = Ui_graphEditorWidget()
|
||||
self.ui.setupUi(self)
|
||||
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.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE)
|
||||
self.ui.graphicsView.setScene(self.scene)
|
||||
@@ -117,65 +160,120 @@ class GraphEditorWidget(QWidget):
|
||||
self.ui.actionZoomToFit.triggered.connect(self.zoom_to_fit)
|
||||
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:
|
||||
if component is not None and not isinstance(component.implementation, GraphImplementation):
|
||||
raise TypeError("GraphEditorWidget only supports components with a graph implementation")
|
||||
component_changed = component is not self._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 = graph or Graph()
|
||||
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)}
|
||||
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():
|
||||
icon = icons.get(component_id, Icon())
|
||||
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.setPos(*positions[component_id])
|
||||
item.setToolTip(child.name)
|
||||
item.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
component_items[component_id] = item
|
||||
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():
|
||||
if not isinstance(connection, (SignalConnection, BondConnection)):
|
||||
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
|
||||
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(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))
|
||||
self._connection_items[connection_id] = connection_item
|
||||
self.scene.addItem(connection_item)
|
||||
|
||||
for item in component_items.values():
|
||||
self.scene.addItem(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 _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]
|
||||
target = points[-1]
|
||||
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:
|
||||
return points
|
||||
clipped = list(points)
|
||||
clipped[0] = GraphEditorWidget._bounding_box_edge(source_bounds, source_direction)
|
||||
clipped[-1] = GraphEditorWidget._bounding_box_edge(target_bounds, target_direction)
|
||||
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, 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)
|
||||
center = bounds.center()
|
||||
dx = toward[0] - center.x()
|
||||
dy = toward[1] - center.y()
|
||||
horizontal_scale = bounds.width() / 2 / abs(dx) if dx else float("inf")
|
||||
vertical_scale = bounds.height() / 2 / abs(dy) if dy else float("inf")
|
||||
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 center.x() + dx * scale, center.y() + dy * scale
|
||||
return origin[0] + dx * scale, origin[1] + dy * scale
|
||||
|
||||
def component(self) -> Component | None:
|
||||
return self._component
|
||||
|
||||
@@ -7,7 +7,7 @@ from bedit_gui.views.graph_editor_widget import GraphEditorWidget
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, snap_to_grid_size: int = 4) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.ui = Ui_MainWindow()
|
||||
@@ -18,7 +18,7 @@ class MainWindow(QMainWindow):
|
||||
self.equation_editor = EquationEditorWidget(self.ui.centralwidget)
|
||||
self.equation_editor.hide()
|
||||
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()
|
||||
central_layout.addWidget(self.graph_editor)
|
||||
|
||||
|
||||
@@ -438,8 +438,8 @@
|
||||
-32,
|
||||
-32
|
||||
],
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"width": 64.0,
|
||||
"height": 64.0,
|
||||
"color": "#000000ff",
|
||||
"bold": true,
|
||||
"italic": false,
|
||||
@@ -596,32 +596,32 @@
|
||||
"shapes": {},
|
||||
"component_positions": {
|
||||
"43b3aee6-0b38-429b-9c6d-d38fc097297f": [
|
||||
128,
|
||||
-256
|
||||
64,
|
||||
-384
|
||||
],
|
||||
"9ee2f42b-5ed1-446d-8790-c2a1df6b61d3": [
|
||||
-128,
|
||||
64,
|
||||
-128
|
||||
],
|
||||
"5a8b2e8d-489f-467b-8282-7e344dfad576": [
|
||||
128,
|
||||
-64
|
||||
320,
|
||||
-128
|
||||
],
|
||||
"2804f2f1-6123-4a53-aff1-87a3a8202711": [
|
||||
-320,
|
||||
-192,
|
||||
-128
|
||||
],
|
||||
"9f392c28-6bef-4b10-9305-93e250747007": [
|
||||
128,
|
||||
160
|
||||
64,
|
||||
128
|
||||
],
|
||||
"77701f68-b14c-4b98-8929-c5fba0261962": [
|
||||
352,
|
||||
96
|
||||
320,
|
||||
128
|
||||
],
|
||||
"033b930e-bf79-403a-8b0b-a159f3c81ce9": [
|
||||
352,
|
||||
224
|
||||
-192,
|
||||
128
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
|
||||
Reference in New Issue
Block a user