diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py
index 68af787..6064024 100644
--- a/src/bedit_gui/application.py
+++ b/src/bedit_gui/application.py
@@ -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)
diff --git a/src/bedit_gui/commands/graph_position_command.py b/src/bedit_gui/commands/graph_position_command.py
new file mode 100644
index 0000000..7f92127
--- /dev/null
+++ b/src/bedit_gui/commands/graph_position_command.py
@@ -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)
diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py
index ba47a63..11ef119 100644
--- a/src/bedit_gui/controllers/document_tree_controller.py
+++ b/src/bedit_gui/controllers/document_tree_controller.py
@@ -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
diff --git a/src/bedit_gui/controllers/settings_controller.py b/src/bedit_gui/controllers/settings_controller.py
index 637cea6..928290e 100644
--- a/src/bedit_gui/controllers/settings_controller.py
+++ b/src/bedit_gui/controllers/settings_controller.py
@@ -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")
diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py
index 0f83b6a..d82efe7 100644
--- a/src/bedit_gui/documents/document.py
+++ b/src/bedit_gui/documents/document.py
@@ -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))
diff --git a/src/bedit_gui/services/application_settings.py b/src/bedit_gui/services/application_settings.py
index 35e2330..651d642 100644
--- a/src/bedit_gui/services/application_settings.py
+++ b/src/bedit_gui/services/application_settings.py
@@ -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."""
diff --git a/src/bedit_gui/ui/forms/settings_dialog.ui b/src/bedit_gui/ui/forms/settings_dialog.ui
index 8b54520..315d4ef 100644
--- a/src/bedit_gui/ui/forms/settings_dialog.ui
+++ b/src/bedit_gui/ui/forms/settings_dialog.ui
@@ -55,7 +55,27 @@
+ -
+
+
+ Snap-to-grid size:
+
+
+
-
+
+
+ 1
+
+
+ 256
+
+
+ 4
+
+
+
+ -
Qt::Orientation::Vertical
diff --git a/src/bedit_gui/views/dialogs/settings_dialog.py b/src/bedit_gui/views/dialogs/settings_dialog.py
index 33fd9df..a8bc8f5 100644
--- a/src/bedit_gui/views/dialogs/settings_dialog.py
+++ b/src/bedit_gui/views/dialogs/settings_dialog.py
@@ -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()
diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py
index 181c2cf..f3f47ec 100644
--- a/src/bedit_gui/views/graph_editor_widget.py
+++ b/src/bedit_gui/views/graph_editor_widget.py
@@ -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
diff --git a/src/bedit_gui/views/main_window.py b/src/bedit_gui/views/main_window.py
index c7f7dd1..8e20c77 100644
--- a/src/bedit_gui/views/main_window.py
+++ b/src/bedit_gui/views/main_window.py
@@ -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)
diff --git a/untitled.bedit.json b/untitled.bedit.json
index 472f1b1..3792448 100644
--- a/untitled.bedit.json
+++ b/untitled.bedit.json
@@ -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": {