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": {