From 0c93338e39d309dfbf53aec959b1fea7be39d14a Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Sat, 15 Aug 2026 17:18:51 +0200 Subject: [PATCH] Start of graph view --- .../controllers/document_tree_controller.py | 12 ++- src/bedit_gui/ui/forms/graph_editor_widget.ui | 98 +++++++++++++++++++ src/bedit_gui/views/graph_editor_widget.py | 83 ++++++++++++++++ src/bedit_gui/views/main_window.py | 4 + 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 src/bedit_gui/ui/forms/graph_editor_widget.ui create mode 100644 src/bedit_gui/views/graph_editor_widget.py diff --git a/src/bedit_gui/controllers/document_tree_controller.py b/src/bedit_gui/controllers/document_tree_controller.py index 0af36db..3dbb05a 100644 --- a/src/bedit_gui/controllers/document_tree_controller.py +++ b/src/bedit_gui/controllers/document_tree_controller.py @@ -96,7 +96,7 @@ class DocumentTreeController(QObject): def _on_document_changed(self, model: CoreDocument) -> None: """Rebuild the tree whenever New/Open replaces the core document.""" - self._show_equation_component(None) + self._show_component(None) self.model.set_document(model) self._components = {} self._collect_components(model.root) @@ -111,15 +111,21 @@ class DocumentTreeController(QObject): def _selection_changed(self, *_args: object) -> None: indexes = self.window.ui.documentTree.selectionModel().selectedRows(0) component = self.model.value(indexes[0]) if len(indexes) == 1 else None - self._show_equation_component(component if isinstance(component, Component) else None) + self._show_component(component if isinstance(component, Component) else None) - def _show_equation_component(self, component: Component | None) -> None: + def _show_component(self, component: Component | None) -> None: if component is not None and isinstance(component.implementation, EquationImplementation): self.window.equation_editor.set_component(component) self.window.equation_editor.show() else: self.window.equation_editor.set_component(None) self.window.equation_editor.hide() + if component is not None and isinstance(component.implementation, GraphImplementation): + self.window.graph_editor.set_component(component) + self.window.graph_editor.show() + else: + self.window.graph_editor.set_component(None) + self.window.graph_editor.hide() def _on_equation_text_changed(self, component: Component, section: str) -> None: if self.window.equation_editor.component() is component: diff --git a/src/bedit_gui/ui/forms/graph_editor_widget.ui b/src/bedit_gui/ui/forms/graph_editor_widget.ui new file mode 100644 index 0000000..6ff968d --- /dev/null +++ b/src/bedit_gui/ui/forms/graph_editor_widget.ui @@ -0,0 +1,98 @@ + + + graphEditorWidget + + + + 0 + 0 + 1079 + 730 + + + + Graph Editor + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Graph tools + + + false + + + false + + + + + + + + + + + QFrame::Shape::NoFrame + + + QGraphicsView::DragMode::RubberBandDrag + + + QPainter::RenderHint::Antialiasing + + + + + + + Select + + + Select graph items + + + + + Add Component + + + Add a component + + + + + Add Connection + + + Add a connection + + + + + Delete + + + Delete selected graph items + + + + + + diff --git a/src/bedit_gui/views/graph_editor_widget.py b/src/bedit_gui/views/graph_editor_widget.py new file mode 100644 index 0000000..12e1b9a --- /dev/null +++ b/src/bedit_gui/views/graph_editor_widget.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from PySide6.QtCore import QEvent, QObject, QRectF, Qt +from PySide6.QtGui import QColor, QPainter, QPen, QWheelEvent +from PySide6.QtWidgets import QGraphicsScene, QGraphicsView, QWidget + +from bedit_core.models import Component, GraphImplementation +from bedit_gui.ui.generated.ui_graph_editor_widget import Ui_graphEditorWidget + +GRID_SPACING = 32 +SCENE_SIZE = 10000 +MIN_ZOOM = 0.2 +MAX_ZOOM = 4.0 +ZOOM_STEP = 1.15 + + +class GraphGraphicsScene(QGraphicsScene): + """Graph canvas with a lightweight dotted-line grid.""" + + def drawBackground(self, painter: QPainter, rect: QRectF) -> None: + painter.fillRect(rect, QColor("white")) + pen = QPen(QColor(205, 205, 205), 0, Qt.PenStyle.DotLine) + painter.setPen(pen) + + 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()) + x += GRID_SPACING + y = top + while y <= rect.bottom(): + painter.drawLine(rect.left(), y, rect.right(), y) + y += GRID_SPACING + + +class GraphEditorWidget(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.ui = Ui_graphEditorWidget() + self.ui.setupUi(self) + self._component: Component | None = None + self.scene = GraphGraphicsScene(self) + self.scene.setSceneRect(-SCENE_SIZE / 2, -SCENE_SIZE / 2, SCENE_SIZE, SCENE_SIZE) + self.ui.graphicsView.setScene(self.scene) + self.ui.graphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.ui.graphicsView.viewport().installEventFilter(self) + self.ui.graphicsView.centerOn(0, 0) + + def set_component(self, component: Component | None) -> None: + if component is not None and not isinstance(component.implementation, GraphImplementation): + raise TypeError("GraphEditorWidget only supports components with a graph implementation") + self._component = component + + def component(self) -> Component | None: + return self._component + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched is self.ui.graphicsView.viewport() and event.type() == QEvent.Type.Wheel: + assert isinstance(event, QWheelEvent) + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + self._zoom(event) + return True + if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: + self._scroll_horizontally(event) + return True + return super().eventFilter(watched, event) + + def _zoom(self, event: QWheelEvent) -> None: + delta = event.angleDelta().y() or event.pixelDelta().y() + if not delta: + return + current_zoom = self.ui.graphicsView.transform().m11() + requested_zoom = current_zoom * ZOOM_STEP ** (delta / 120) + target_zoom = min(MAX_ZOOM, max(MIN_ZOOM, requested_zoom)) + self.ui.graphicsView.scale(target_zoom / current_zoom, target_zoom / current_zoom) + + def _scroll_horizontally(self, event: QWheelEvent) -> None: + scrollbar = self.ui.graphicsView.horizontalScrollBar() + pixel_delta = event.pixelDelta().y() + distance = pixel_delta if pixel_delta else event.angleDelta().y() / 120 * scrollbar.singleStep() * 3 + scrollbar.setValue(scrollbar.value() - round(distance)) diff --git a/src/bedit_gui/views/main_window.py b/src/bedit_gui/views/main_window.py index 4320db5..c7f7dd1 100644 --- a/src/bedit_gui/views/main_window.py +++ b/src/bedit_gui/views/main_window.py @@ -3,6 +3,7 @@ from PySide6.QtWidgets import QMainWindow, QTabWidget, QVBoxLayout from bedit_gui.ui.generated.ui_main_window import Ui_MainWindow from bedit_gui.views.equation_editor_widget import EquationEditorWidget +from bedit_gui.views.graph_editor_widget import GraphEditorWidget class MainWindow(QMainWindow): @@ -17,6 +18,9 @@ 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.hide() + central_layout.addWidget(self.graph_editor) self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) self.setCorner(Qt.Corner.BottomLeftCorner, Qt.DockWidgetArea.LeftDockWidgetArea)