feat(ui): panels, canvas view, animation, and main window
Add left node-library dock (grouped, searchable, drag-and-drop vector symbols), right property dock with type-aware grouped editors (incl. geometry, catalogue single/multi, color, value list), zoom/pan/fit canvas view accepting node drops, flow animator, configuration dialog, and the MainWindow wiring menus/toolbar, undo/redo, delete/duplicate, calculate flow, save/open (JSON), and PNG/SVG export. Includes a demo sample network, entry points, and panel + app integration tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
af66debcde
commit
ae3f752b6f
4
pipeline_editor/__main__.py
Normal file
4
pipeline_editor/__main__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
22
pipeline_editor/app.py
Normal file
22
pipeline_editor/app.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"""Application entry point."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from .main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
argv = list(sys.argv if argv is None else argv)
|
||||||
|
app = QApplication(argv)
|
||||||
|
app.setApplicationName("Pipeline Network Editor")
|
||||||
|
window = MainWindow()
|
||||||
|
window.load_sample()
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
46
pipeline_editor/calc/flow_animator.py
Normal file
46
pipeline_editor/calc/flow_animator.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""Drives the marching-dash flow animation on edge items via a timer."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QObject, QTimer
|
||||||
|
|
||||||
|
|
||||||
|
class FlowAnimator(QObject):
|
||||||
|
"""Advances an animation phase and pushes it to every edge item."""
|
||||||
|
|
||||||
|
def __init__(self, scene, config, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._scene = scene
|
||||||
|
self._config = config
|
||||||
|
self._phase = 0.0
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.setInterval(40) # ~25 fps
|
||||||
|
self._timer.timeout.connect(self._tick)
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self.running:
|
||||||
|
self.running = True
|
||||||
|
self._timer.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.running = False
|
||||||
|
self._timer.stop()
|
||||||
|
|
||||||
|
def toggle(self) -> None:
|
||||||
|
self.stop() if self.running else self.start()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._phase = 0.0
|
||||||
|
self._push()
|
||||||
|
|
||||||
|
def step(self) -> None:
|
||||||
|
"""Advance one frame (used for tests without a running event loop)."""
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
self._phase += max(0.1, self._config.animation_speed)
|
||||||
|
self._push()
|
||||||
|
|
||||||
|
def _push(self) -> None:
|
||||||
|
for item in self._scene.edge_items.values():
|
||||||
|
item.set_phase(self._phase)
|
||||||
@ -105,6 +105,32 @@ class MoveNodeCommand(QUndoCommand):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SetGeometryCommand(QUndoCommand):
|
||||||
|
"""Undoable edit of a node geometry attribute (x/y/width/height/rotation)."""
|
||||||
|
|
||||||
|
_FIELDS = ("x", "y", "width", "height", "rotation")
|
||||||
|
|
||||||
|
def __init__(self, doc: DiagramDocument, node_id: str, field: str, old, new):
|
||||||
|
super().__init__(f"Edit {field}")
|
||||||
|
self._doc = doc
|
||||||
|
self._id = node_id
|
||||||
|
self._field = field
|
||||||
|
self._old = old
|
||||||
|
self._new = new
|
||||||
|
|
||||||
|
def _apply(self, value):
|
||||||
|
node = self._doc.node(self._id)
|
||||||
|
if node is not None and self._field in self._FIELDS:
|
||||||
|
setattr(node, self._field, float(value))
|
||||||
|
self._doc.notify_node_changed(self._id)
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
self._apply(self._new)
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
self._apply(self._old)
|
||||||
|
|
||||||
|
|
||||||
class SetPropertyCommand(QUndoCommand):
|
class SetPropertyCommand(QUndoCommand):
|
||||||
def __init__(self, doc: DiagramDocument, kind: str, owner_id: str,
|
def __init__(self, doc: DiagramDocument, kind: str, owner_id: str,
|
||||||
key: str, old, new):
|
key: str, old, new):
|
||||||
@ -166,6 +192,25 @@ class EditorController:
|
|||||||
return
|
return
|
||||||
self.undo_stack.push(DeleteCommand(self.document, node_ids, edge_ids))
|
self.undo_stack.push(DeleteCommand(self.document, node_ids, edge_ids))
|
||||||
|
|
||||||
|
def duplicate(self, node_ids, offset=(20, 20)) -> list:
|
||||||
|
"""Clone the given nodes (offset a little) as one undoable step."""
|
||||||
|
clones = []
|
||||||
|
ids = list(node_ids)
|
||||||
|
if not ids:
|
||||||
|
return clones
|
||||||
|
self.undo_stack.beginMacro("Duplicate")
|
||||||
|
for nid in ids:
|
||||||
|
node = self.document.node(nid)
|
||||||
|
if node is None:
|
||||||
|
continue
|
||||||
|
clone = node.clone(self.document.next_id("n"))
|
||||||
|
clone.x += offset[0]
|
||||||
|
clone.y += offset[1]
|
||||||
|
self.undo_stack.push(AddNodeCommand(self.document, clone))
|
||||||
|
clones.append(clone)
|
||||||
|
self.undo_stack.endMacro()
|
||||||
|
return clones
|
||||||
|
|
||||||
def record_move(self, node_id: str, old, new) -> None:
|
def record_move(self, node_id: str, old, new) -> None:
|
||||||
if old == new:
|
if old == new:
|
||||||
return
|
return
|
||||||
@ -184,6 +229,15 @@ class EditorController:
|
|||||||
return
|
return
|
||||||
self.undo_stack.push(SetPropertyCommand(self.document, kind, owner_id, key, old, value))
|
self.undo_stack.push(SetPropertyCommand(self.document, kind, owner_id, key, old, value))
|
||||||
|
|
||||||
|
def set_geometry(self, node_id: str, field: str, value) -> None:
|
||||||
|
node = self.document.node(node_id)
|
||||||
|
if node is None or not hasattr(node, field):
|
||||||
|
return
|
||||||
|
old = getattr(node, field)
|
||||||
|
if float(old) == float(value):
|
||||||
|
return
|
||||||
|
self.undo_stack.push(SetGeometryCommand(self.document, node_id, field, old, value))
|
||||||
|
|
||||||
# convenience
|
# convenience
|
||||||
def undo(self):
|
def undo(self):
|
||||||
self.undo_stack.undo()
|
self.undo_stack.undo()
|
||||||
|
|||||||
329
pipeline_editor/main_window.py
Normal file
329
pipeline_editor/main_window.py
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
"""MainWindow: assembles docks, canvas, menus, calculation and file IO."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtGui import QAction, QKeySequence, QPainter
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDockWidget, QFileDialog, QMainWindow, QMessageBox,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .calc import flow_solver
|
||||||
|
from .calc.flow_animator import FlowAnimator
|
||||||
|
from .config import AppConfig
|
||||||
|
from .controller import EditorController
|
||||||
|
from .model.document import DiagramDocument
|
||||||
|
from .model.node_library import default_library
|
||||||
|
from .panels.config_dialog import ConfigDialog
|
||||||
|
from .panels.node_panel import NodePanel
|
||||||
|
from .panels.property_panel import PropertyPanel
|
||||||
|
from .view.canvas_view import CanvasView
|
||||||
|
from .view.node_item import NodeItem
|
||||||
|
from .view.edge_item import EdgeItem
|
||||||
|
from .view.scene import DiagramScene
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Pipeline Network Editor")
|
||||||
|
self.resize(1280, 820)
|
||||||
|
|
||||||
|
self.library = default_library()
|
||||||
|
self.document = DiagramDocument()
|
||||||
|
self.config = AppConfig()
|
||||||
|
self.controller = EditorController(self.document, self.library)
|
||||||
|
|
||||||
|
self.scene = DiagramScene(self.document, self.config, controller=self.controller)
|
||||||
|
self.view = CanvasView(self.scene)
|
||||||
|
self.setCentralWidget(self.view)
|
||||||
|
self.animator = FlowAnimator(self.scene, self.config)
|
||||||
|
|
||||||
|
self._current_path: str | None = None
|
||||||
|
self._selected_model = None
|
||||||
|
|
||||||
|
self._build_docks()
|
||||||
|
self._build_actions()
|
||||||
|
self._build_menus()
|
||||||
|
self._build_toolbar()
|
||||||
|
self.statusBar().showMessage("Ready")
|
||||||
|
|
||||||
|
self.scene.selection_changed.connect(self._on_selection)
|
||||||
|
self.document.node_changed.connect(self._on_owner_changed)
|
||||||
|
self.document.edge_changed.connect(self._on_owner_changed)
|
||||||
|
self.document.modified.connect(self._update_title)
|
||||||
|
self.config.changed.connect(self._on_config_changed)
|
||||||
|
self.controller.undo_stack.indexChanged.connect(self._update_title)
|
||||||
|
|
||||||
|
# -- UI construction --------------------------------------------------
|
||||||
|
def _build_docks(self) -> None:
|
||||||
|
self.node_panel = NodePanel(self.library)
|
||||||
|
dock_l = QDockWidget("Nodes", self)
|
||||||
|
dock_l.setWidget(self.node_panel)
|
||||||
|
dock_l.setAllowedAreas(Qt.DockWidgetArea.LeftDockWidgetArea
|
||||||
|
| Qt.DockWidgetArea.RightDockWidgetArea)
|
||||||
|
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock_l)
|
||||||
|
|
||||||
|
self.property_panel = PropertyPanel(self.document.catalogues)
|
||||||
|
self.property_panel.property_edited.connect(self._on_property_edited)
|
||||||
|
self.property_panel.geometry_edited.connect(self._on_geometry_edited)
|
||||||
|
dock_r = QDockWidget("Properties", self)
|
||||||
|
dock_r.setWidget(self.property_panel)
|
||||||
|
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock_r)
|
||||||
|
|
||||||
|
def _build_actions(self) -> None:
|
||||||
|
c = self.controller
|
||||||
|
self.act_new = QAction("&New", self, shortcut=QKeySequence.StandardKey.New,
|
||||||
|
triggered=self.new_document)
|
||||||
|
self.act_open = QAction("&Open…", self, shortcut=QKeySequence.StandardKey.Open,
|
||||||
|
triggered=self.open_document)
|
||||||
|
self.act_save = QAction("&Save", self, shortcut=QKeySequence.StandardKey.Save,
|
||||||
|
triggered=self.save_document)
|
||||||
|
self.act_save_as = QAction("Save &As…", self,
|
||||||
|
shortcut=QKeySequence.StandardKey.SaveAs,
|
||||||
|
triggered=self.save_document_as)
|
||||||
|
self.act_export_png = QAction("Export PNG…", self, triggered=self.export_png)
|
||||||
|
self.act_export_svg = QAction("Export SVG…", self, triggered=self.export_svg)
|
||||||
|
self.act_quit = QAction("&Quit", self, shortcut=QKeySequence.StandardKey.Quit,
|
||||||
|
triggered=self.close)
|
||||||
|
|
||||||
|
self.act_undo = c.undo_stack.createUndoAction(self, "&Undo")
|
||||||
|
self.act_undo.setShortcut(QKeySequence.StandardKey.Undo)
|
||||||
|
self.act_redo = c.undo_stack.createRedoAction(self, "&Redo")
|
||||||
|
self.act_redo.setShortcut(QKeySequence.StandardKey.Redo)
|
||||||
|
self.act_delete = QAction("&Delete", self, shortcut=QKeySequence.StandardKey.Delete,
|
||||||
|
triggered=self.delete_selection)
|
||||||
|
self.act_dup = QAction("Du&plicate", self, shortcut="Ctrl+D",
|
||||||
|
triggered=self.duplicate_selection)
|
||||||
|
|
||||||
|
self.act_zoom_in = QAction("Zoom In", self, shortcut=QKeySequence.StandardKey.ZoomIn,
|
||||||
|
triggered=lambda: self.view.scale(1.2, 1.2))
|
||||||
|
self.act_zoom_out = QAction("Zoom Out", self,
|
||||||
|
shortcut=QKeySequence.StandardKey.ZoomOut,
|
||||||
|
triggered=lambda: self.view.scale(1 / 1.2, 1 / 1.2))
|
||||||
|
self.act_fit = QAction("Fit", self, shortcut="Ctrl+0",
|
||||||
|
triggered=self.view.fit_to_contents)
|
||||||
|
self.act_grid = QAction("Show Grid", self, checkable=True,
|
||||||
|
checked=self.config.show_grid, triggered=self._toggle_grid)
|
||||||
|
self.act_snap = QAction("Snap to Grid", self, checkable=True,
|
||||||
|
checked=self.config.snap_to_grid, triggered=self._toggle_snap)
|
||||||
|
|
||||||
|
self.act_solve = QAction("Calculate Flow", self, shortcut="F5",
|
||||||
|
triggered=self.solve_flow)
|
||||||
|
self.act_play = QAction("Play Flow", self, checkable=True, triggered=self.toggle_flow)
|
||||||
|
self.act_config = QAction("Configuration…", self, triggered=self.open_config)
|
||||||
|
self.act_sample = QAction("Load Sample", self, triggered=self.load_sample)
|
||||||
|
|
||||||
|
def _build_menus(self) -> None:
|
||||||
|
m = self.menuBar()
|
||||||
|
fm = m.addMenu("&File")
|
||||||
|
fm.addActions([self.act_new, self.act_open, self.act_save, self.act_save_as])
|
||||||
|
fm.addSeparator()
|
||||||
|
fm.addActions([self.act_export_png, self.act_export_svg])
|
||||||
|
fm.addSeparator()
|
||||||
|
fm.addAction(self.act_quit)
|
||||||
|
|
||||||
|
em = m.addMenu("&Edit")
|
||||||
|
em.addActions([self.act_undo, self.act_redo])
|
||||||
|
em.addSeparator()
|
||||||
|
em.addActions([self.act_delete, self.act_dup])
|
||||||
|
|
||||||
|
vm = m.addMenu("&View")
|
||||||
|
vm.addActions([self.act_zoom_in, self.act_zoom_out, self.act_fit])
|
||||||
|
vm.addSeparator()
|
||||||
|
vm.addActions([self.act_grid, self.act_snap])
|
||||||
|
|
||||||
|
cm = m.addMenu("&Calculate")
|
||||||
|
cm.addActions([self.act_solve, self.act_play])
|
||||||
|
|
||||||
|
sm = m.addMenu("&Settings")
|
||||||
|
sm.addAction(self.act_config)
|
||||||
|
|
||||||
|
hm = m.addMenu("&Help")
|
||||||
|
hm.addAction(self.act_sample)
|
||||||
|
|
||||||
|
def _build_toolbar(self) -> None:
|
||||||
|
tb = self.addToolBar("Main")
|
||||||
|
tb.setMovable(False)
|
||||||
|
tb.addActions([self.act_new, self.act_open, self.act_save])
|
||||||
|
tb.addSeparator()
|
||||||
|
tb.addActions([self.act_undo, self.act_redo, self.act_delete, self.act_dup])
|
||||||
|
tb.addSeparator()
|
||||||
|
tb.addActions([self.act_zoom_in, self.act_zoom_out, self.act_fit])
|
||||||
|
tb.addSeparator()
|
||||||
|
tb.addActions([self.act_solve, self.act_play])
|
||||||
|
|
||||||
|
# -- selection & property editing ------------------------------------
|
||||||
|
def _on_selection(self, model) -> None:
|
||||||
|
self._selected_model = model
|
||||||
|
self.property_panel.set_target(model)
|
||||||
|
|
||||||
|
def _on_owner_changed(self, owner_id: str) -> None:
|
||||||
|
model = self._selected_model
|
||||||
|
if model is None:
|
||||||
|
return
|
||||||
|
mid = getattr(model, "node_id", None) or getattr(model, "edge_id", None)
|
||||||
|
if mid == owner_id:
|
||||||
|
self.property_panel.refresh(model)
|
||||||
|
|
||||||
|
def _on_property_edited(self, kind, owner_id, key, value) -> None:
|
||||||
|
self.controller.set_property(kind, owner_id, key, value)
|
||||||
|
|
||||||
|
def _on_geometry_edited(self, node_id, field, value) -> None:
|
||||||
|
self.controller.set_geometry(node_id, field, value)
|
||||||
|
|
||||||
|
def _selected_ids(self):
|
||||||
|
node_ids, edge_ids = [], []
|
||||||
|
for it in self.scene.selectedItems():
|
||||||
|
if isinstance(it, NodeItem):
|
||||||
|
node_ids.append(it.node.node_id)
|
||||||
|
elif isinstance(it, EdgeItem):
|
||||||
|
edge_ids.append(it.edge.edge_id)
|
||||||
|
return node_ids, edge_ids
|
||||||
|
|
||||||
|
def delete_selection(self) -> None:
|
||||||
|
node_ids, edge_ids = self._selected_ids()
|
||||||
|
self.controller.delete(node_ids, edge_ids)
|
||||||
|
|
||||||
|
def duplicate_selection(self) -> None:
|
||||||
|
node_ids, _ = self._selected_ids()
|
||||||
|
self.controller.duplicate(node_ids, offset=(self.config.grid_size, self.config.grid_size))
|
||||||
|
|
||||||
|
# -- view toggles -----------------------------------------------------
|
||||||
|
def _toggle_grid(self, checked) -> None:
|
||||||
|
self.config.show_grid = checked
|
||||||
|
self.config.emit_changed()
|
||||||
|
|
||||||
|
def _toggle_snap(self, checked) -> None:
|
||||||
|
self.config.snap_to_grid = checked
|
||||||
|
|
||||||
|
def _on_config_changed(self) -> None:
|
||||||
|
self.document.relations.set_active_scheme(self.config.color_scheme)
|
||||||
|
self.act_grid.setChecked(self.config.show_grid)
|
||||||
|
self.act_snap.setChecked(self.config.snap_to_grid)
|
||||||
|
self.scene.update()
|
||||||
|
self.view.viewport().update()
|
||||||
|
|
||||||
|
def open_config(self) -> None:
|
||||||
|
dlg = ConfigDialog(self.config, self.document.relations.scheme_names(), self)
|
||||||
|
if dlg.exec():
|
||||||
|
dlg.apply()
|
||||||
|
|
||||||
|
# -- calculation ------------------------------------------------------
|
||||||
|
def solve_flow(self) -> None:
|
||||||
|
result = flow_solver.apply_to_document(self.document)
|
||||||
|
for item in self.scene.edge_items.values():
|
||||||
|
item.update()
|
||||||
|
served = sum(abs(f) for f in result.edge_flow.values())
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Flow solved: supply {result.total_supply:.0f}, "
|
||||||
|
f"demand {result.total_demand:.0f}, {served:.0f} distributed")
|
||||||
|
if not self.animator.running:
|
||||||
|
self.act_play.setChecked(True)
|
||||||
|
self.toggle_flow(True)
|
||||||
|
|
||||||
|
def toggle_flow(self, checked) -> None:
|
||||||
|
if checked:
|
||||||
|
self.animator.start()
|
||||||
|
else:
|
||||||
|
self.animator.stop()
|
||||||
|
|
||||||
|
# -- file IO ----------------------------------------------------------
|
||||||
|
def _maybe_discard(self) -> bool:
|
||||||
|
if not self.document.dirty:
|
||||||
|
return True
|
||||||
|
resp = QMessageBox.question(
|
||||||
|
self, "Unsaved changes", "Discard unsaved changes?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||||
|
return resp == QMessageBox.StandardButton.Yes
|
||||||
|
|
||||||
|
def new_document(self) -> None:
|
||||||
|
if not self._maybe_discard():
|
||||||
|
return
|
||||||
|
self.document.clear()
|
||||||
|
self.controller.undo_stack.clear()
|
||||||
|
self._current_path = None
|
||||||
|
self.document.dirty = False
|
||||||
|
self._update_title()
|
||||||
|
|
||||||
|
def open_document(self) -> None:
|
||||||
|
if not self._maybe_discard():
|
||||||
|
return
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, "Open", "", "Pipeline (*.pipe *.json)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
self.document.load_dict(json.load(fh))
|
||||||
|
self.controller.undo_stack.clear()
|
||||||
|
self._current_path = path
|
||||||
|
self.document.dirty = False
|
||||||
|
self._update_title()
|
||||||
|
|
||||||
|
def save_document(self) -> None:
|
||||||
|
if self._current_path is None:
|
||||||
|
self.save_document_as()
|
||||||
|
return
|
||||||
|
with open(self._current_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(self.document.to_json())
|
||||||
|
self.document.dirty = False
|
||||||
|
self._update_title()
|
||||||
|
self.statusBar().showMessage(f"Saved {self._current_path}")
|
||||||
|
|
||||||
|
def save_document_as(self) -> None:
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self, "Save As", "diagram.pipe",
|
||||||
|
"Pipeline (*.pipe *.json)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
self._current_path = path
|
||||||
|
self.save_document()
|
||||||
|
|
||||||
|
def export_png(self) -> None:
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self, "Export PNG", "diagram.png", "PNG (*.png)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
from PyQt6.QtGui import QImage
|
||||||
|
rect = self.scene.itemsBoundingRect().adjusted(-20, -20, 20, 20)
|
||||||
|
if rect.isNull():
|
||||||
|
return
|
||||||
|
image = QImage(int(rect.width()), int(rect.height()), QImage.Format.Format_ARGB32)
|
||||||
|
image.fill(Qt.GlobalColor.white)
|
||||||
|
painter = QPainter(image)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
self.scene.render(painter, target=image.rect().toRectF(), source=rect)
|
||||||
|
painter.end()
|
||||||
|
image.save(path)
|
||||||
|
self.statusBar().showMessage(f"Exported {path}")
|
||||||
|
|
||||||
|
def export_svg(self) -> None:
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self, "Export SVG", "diagram.svg", "SVG (*.svg)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
from PyQt6.QtSvg import QSvgGenerator
|
||||||
|
rect = self.scene.itemsBoundingRect().adjusted(-20, -20, 20, 20)
|
||||||
|
gen = QSvgGenerator()
|
||||||
|
gen.setFileName(path)
|
||||||
|
gen.setSize(rect.size().toSize())
|
||||||
|
gen.setViewBox(rect)
|
||||||
|
painter = QPainter(gen)
|
||||||
|
self.scene.render(painter, target=rect, source=rect)
|
||||||
|
painter.end()
|
||||||
|
self.statusBar().showMessage(f"Exported {path}")
|
||||||
|
|
||||||
|
# -- misc -------------------------------------------------------------
|
||||||
|
def _update_title(self) -> None:
|
||||||
|
name = os.path.basename(self._current_path) if self._current_path else "Untitled"
|
||||||
|
star = "*" if self.document.dirty else ""
|
||||||
|
self.setWindowTitle(f"{name}{star} — Pipeline Network Editor")
|
||||||
|
|
||||||
|
def load_sample(self) -> None:
|
||||||
|
from .sample import build_sample
|
||||||
|
if not self._maybe_discard():
|
||||||
|
return
|
||||||
|
self.document.clear()
|
||||||
|
self.controller.undo_stack.clear()
|
||||||
|
build_sample(self.controller)
|
||||||
|
self.document.dirty = False
|
||||||
|
self.view.fit_to_contents()
|
||||||
|
self.solve_flow()
|
||||||
78
pipeline_editor/panels/config_dialog.py
Normal file
78
pipeline_editor/panels/config_dialog.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Configuration dialog for grid, routing, animation and theming options."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
|
||||||
|
QSpinBox, QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigDialog(QDialog):
|
||||||
|
"""Edits an :class:`AppConfig` in place; applies on accept."""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig, scheme_names: list[str], parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Editor Configuration")
|
||||||
|
self.config = config
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
layout.addLayout(form)
|
||||||
|
|
||||||
|
self.grid = QSpinBox()
|
||||||
|
self.grid.setRange(2, 200)
|
||||||
|
self.grid.setValue(config.grid_size)
|
||||||
|
form.addRow("Grid size", self.grid)
|
||||||
|
|
||||||
|
self.show_grid = QCheckBox()
|
||||||
|
self.show_grid.setChecked(config.show_grid)
|
||||||
|
form.addRow("Show grid", self.show_grid)
|
||||||
|
|
||||||
|
self.snap = QCheckBox()
|
||||||
|
self.snap.setChecked(config.snap_to_grid)
|
||||||
|
form.addRow("Snap to grid", self.snap)
|
||||||
|
|
||||||
|
self.arc = QCheckBox()
|
||||||
|
self.arc.setChecked(config.arc_on_crossing)
|
||||||
|
form.addRow("Arc on crossing", self.arc)
|
||||||
|
|
||||||
|
self.arc_radius = QDoubleSpinBox()
|
||||||
|
self.arc_radius.setRange(2, 20)
|
||||||
|
self.arc_radius.setValue(config.arc_radius)
|
||||||
|
form.addRow("Arc radius", self.arc_radius)
|
||||||
|
|
||||||
|
self.stub = QSpinBox()
|
||||||
|
self.stub.setRange(0, 200)
|
||||||
|
self.stub.setValue(config.route_stub)
|
||||||
|
form.addRow("Route stub", self.stub)
|
||||||
|
|
||||||
|
self.anim = QDoubleSpinBox()
|
||||||
|
self.anim.setRange(0.1, 10.0)
|
||||||
|
self.anim.setSingleStep(0.1)
|
||||||
|
self.anim.setValue(config.animation_speed)
|
||||||
|
form.addRow("Animation speed", self.anim)
|
||||||
|
|
||||||
|
self.scheme = QComboBox()
|
||||||
|
self.scheme.addItems(scheme_names)
|
||||||
|
if config.color_scheme in scheme_names:
|
||||||
|
self.scheme.setCurrentText(config.color_scheme)
|
||||||
|
form.addRow("Color scheme", self.scheme)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
layout.addWidget(buttons)
|
||||||
|
|
||||||
|
def apply(self) -> None:
|
||||||
|
c = self.config
|
||||||
|
c.grid_size = self.grid.value()
|
||||||
|
c.show_grid = self.show_grid.isChecked()
|
||||||
|
c.snap_to_grid = self.snap.isChecked()
|
||||||
|
c.arc_on_crossing = self.arc.isChecked()
|
||||||
|
c.arc_radius = self.arc_radius.value()
|
||||||
|
c.route_stub = self.stub.value()
|
||||||
|
c.animation_speed = self.anim.value()
|
||||||
|
c.color_scheme = self.scheme.currentText()
|
||||||
|
c.emit_changed()
|
||||||
100
pipeline_editor/panels/node_panel.py
Normal file
100
pipeline_editor/panels/node_panel.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""Left-dock node library panel: grouped, draggable vector node symbols."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QByteArray, QMimeData, QRectF, Qt
|
||||||
|
from PyQt6.QtGui import QDrag, QIcon, QPainter, QPixmap
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..model.node_library import NodeLibrary
|
||||||
|
from ..view import svg_cache
|
||||||
|
|
||||||
|
NODE_MIME = "application/x-pipeline-node"
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_for(svg_name: str, size: int = 40) -> QIcon:
|
||||||
|
renderer = svg_cache.renderer(svg_name)
|
||||||
|
pm = QPixmap(size, size)
|
||||||
|
pm.fill(Qt.GlobalColor.transparent)
|
||||||
|
if renderer is not None:
|
||||||
|
painter = QPainter(pm)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||||
|
renderer.render(painter, QRectF(2, 2, size - 4, size - 4))
|
||||||
|
painter.end()
|
||||||
|
return QIcon(pm)
|
||||||
|
|
||||||
|
|
||||||
|
class _LibraryTree(QTreeWidget):
|
||||||
|
"""Tree whose leaf items start a drag carrying the template key."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setHeaderHidden(True)
|
||||||
|
self.setDragEnabled(True)
|
||||||
|
self.setIconSize_default()
|
||||||
|
|
||||||
|
def setIconSize_default(self):
|
||||||
|
from PyQt6.QtCore import QSize
|
||||||
|
self.setIconSize(QSize(36, 36))
|
||||||
|
|
||||||
|
def startDrag(self, supported_actions):
|
||||||
|
item = self.currentItem()
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
key = item.data(0, Qt.ItemDataRole.UserRole)
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
mime = QMimeData()
|
||||||
|
mime.setData(NODE_MIME, QByteArray(key.encode("utf-8")))
|
||||||
|
drag = QDrag(self)
|
||||||
|
drag.setMimeData(mime)
|
||||||
|
icon = item.icon(0)
|
||||||
|
if not icon.isNull():
|
||||||
|
drag.setPixmap(icon.pixmap(40, 40))
|
||||||
|
drag.exec(Qt.DropAction.CopyAction)
|
||||||
|
|
||||||
|
|
||||||
|
class NodePanel(QWidget):
|
||||||
|
"""Search box + grouped tree of node templates."""
|
||||||
|
|
||||||
|
def __init__(self, library: NodeLibrary, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.library = library
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
|
||||||
|
self._search = QLineEdit()
|
||||||
|
self._search.setPlaceholderText("Filter nodes…")
|
||||||
|
self._search.textChanged.connect(self._apply_filter)
|
||||||
|
layout.addWidget(self._search)
|
||||||
|
|
||||||
|
self._tree = _LibraryTree()
|
||||||
|
layout.addWidget(self._tree)
|
||||||
|
self._populate()
|
||||||
|
|
||||||
|
def _populate(self) -> None:
|
||||||
|
self._tree.clear()
|
||||||
|
for group_name, templates in sorted(self.library.groups().items()):
|
||||||
|
group_item = QTreeWidgetItem([group_name])
|
||||||
|
group_item.setFlags(group_item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
|
||||||
|
self._tree.addTopLevelItem(group_item)
|
||||||
|
for tmpl in sorted(templates, key=lambda t: t.label):
|
||||||
|
leaf = QTreeWidgetItem([tmpl.label])
|
||||||
|
leaf.setData(0, Qt.ItemDataRole.UserRole, tmpl.key)
|
||||||
|
leaf.setIcon(0, _icon_for(tmpl.svg_name))
|
||||||
|
leaf.setToolTip(0, f"{tmpl.label} — drag onto the canvas")
|
||||||
|
group_item.addChild(leaf)
|
||||||
|
group_item.setExpanded(True)
|
||||||
|
|
||||||
|
def _apply_filter(self, text: str) -> None:
|
||||||
|
text = text.strip().lower()
|
||||||
|
for i in range(self._tree.topLevelItemCount()):
|
||||||
|
group = self._tree.topLevelItem(i)
|
||||||
|
visible_children = 0
|
||||||
|
for j in range(group.childCount()):
|
||||||
|
leaf = group.child(j)
|
||||||
|
match = text in leaf.text(0).lower()
|
||||||
|
leaf.setHidden(bool(text) and not match)
|
||||||
|
visible_children += int(match or not text)
|
||||||
|
group.setHidden(bool(text) and visible_children == 0)
|
||||||
161
pipeline_editor/panels/property_editors.py
Normal file
161
pipeline_editor/panels/property_editors.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"""Per-type property editor widgets used by the property panel."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
from PyQt6.QtGui import QColor
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QCheckBox, QColorDialog, QComboBox, QDoubleSpinBox, QHBoxLayout, QLineEdit,
|
||||||
|
QListWidget, QListWidgetItem, QPushButton, QSpinBox, QWidget,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from ..model.catalogue import CatalogueRegistry
|
||||||
|
from ..model.properties import Property, PropertyType
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyEditor(QWidget):
|
||||||
|
"""A single labelled control bound to a :class:`Property`.
|
||||||
|
|
||||||
|
Emits :attr:`value_changed` with the new (coerced) value on user edits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
value_changed = pyqtSignal(object)
|
||||||
|
|
||||||
|
def __init__(self, prop: Property, catalogues: CatalogueRegistry, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.prop = prop
|
||||||
|
self.catalogues = catalogues
|
||||||
|
self._control: QWidget
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._build()
|
||||||
|
layout.addWidget(self._control)
|
||||||
|
self.setEnabled(prop.editable)
|
||||||
|
|
||||||
|
# -- construction -----------------------------------------------------
|
||||||
|
def _build(self) -> None:
|
||||||
|
t = self.prop.ptype
|
||||||
|
v = self.prop.value
|
||||||
|
if t == PropertyType.STRING:
|
||||||
|
w = QLineEdit(str(v or ""))
|
||||||
|
w.editingFinished.connect(lambda: self.value_changed.emit(w.text()))
|
||||||
|
self._control = w
|
||||||
|
elif t == PropertyType.INT:
|
||||||
|
w = QSpinBox()
|
||||||
|
w.setRange(int(self.prop.minimum) if self.prop.minimum is not None else -10**6,
|
||||||
|
int(self.prop.maximum) if self.prop.maximum is not None else 10**6)
|
||||||
|
w.setValue(int(v or 0))
|
||||||
|
if self.prop.unit:
|
||||||
|
w.setSuffix(f" {self.prop.unit}")
|
||||||
|
w.valueChanged.connect(lambda val: self.value_changed.emit(val))
|
||||||
|
self._control = w
|
||||||
|
elif t == PropertyType.FLOAT:
|
||||||
|
w = QDoubleSpinBox()
|
||||||
|
w.setDecimals(3)
|
||||||
|
w.setRange(self.prop.minimum if self.prop.minimum is not None else -1e9,
|
||||||
|
self.prop.maximum if self.prop.maximum is not None else 1e9)
|
||||||
|
w.setValue(float(v or 0.0))
|
||||||
|
if self.prop.unit:
|
||||||
|
w.setSuffix(f" {self.prop.unit}")
|
||||||
|
w.valueChanged.connect(lambda val: self.value_changed.emit(val))
|
||||||
|
self._control = w
|
||||||
|
elif t == PropertyType.BOOL:
|
||||||
|
w = QCheckBox()
|
||||||
|
w.setChecked(bool(v))
|
||||||
|
w.toggled.connect(lambda val: self.value_changed.emit(val))
|
||||||
|
self._control = w
|
||||||
|
elif t == PropertyType.ENUM:
|
||||||
|
w = QComboBox()
|
||||||
|
w.addItems([str(o) for o in self.prop.options])
|
||||||
|
if v is not None and str(v) in [str(o) for o in self.prop.options]:
|
||||||
|
w.setCurrentText(str(v))
|
||||||
|
w.currentTextChanged.connect(lambda val: self.value_changed.emit(val))
|
||||||
|
self._control = w
|
||||||
|
elif t == PropertyType.COLOR:
|
||||||
|
self._control = self._build_color(v)
|
||||||
|
elif t == PropertyType.CATALOGUE_ITEM:
|
||||||
|
self._control = self._build_catalogue_single(v)
|
||||||
|
elif t == PropertyType.CATALOGUE_ITEMS:
|
||||||
|
self._control = self._build_catalogue_multi(v)
|
||||||
|
elif t == PropertyType.VALUE_LIST:
|
||||||
|
w = QLineEdit(", ".join(str(x) for x in (v or [])))
|
||||||
|
w.setPlaceholderText("comma-separated values")
|
||||||
|
w.editingFinished.connect(
|
||||||
|
lambda: self.value_changed.emit(self._parse_list(w.text())))
|
||||||
|
self._control = w
|
||||||
|
else: # pragma: no cover - defensive
|
||||||
|
self._control = QLineEdit(str(v))
|
||||||
|
|
||||||
|
def _build_color(self, value) -> QWidget:
|
||||||
|
btn = QPushButton(str(value or "#ffffff"))
|
||||||
|
self._color = str(value or "#ffffff")
|
||||||
|
|
||||||
|
def paint():
|
||||||
|
c = QColor(self._color)
|
||||||
|
fg = "#000000" if c.lightnessF() > 0.5 else "#ffffff"
|
||||||
|
btn.setStyleSheet(f"background:{self._color}; color:{fg};")
|
||||||
|
btn.setText(self._color)
|
||||||
|
|
||||||
|
def pick():
|
||||||
|
c = QColorDialog.getColor(QColor(self._color))
|
||||||
|
if c.isValid():
|
||||||
|
self._color = c.name()
|
||||||
|
paint()
|
||||||
|
self.value_changed.emit(self._color)
|
||||||
|
|
||||||
|
btn.clicked.connect(pick)
|
||||||
|
paint()
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _build_catalogue_single(self, value) -> QWidget:
|
||||||
|
w = QComboBox()
|
||||||
|
cat = self.catalogues.get(self.prop.catalogue) if self.prop.catalogue else None
|
||||||
|
self._ids: list[str] = []
|
||||||
|
if cat is not None:
|
||||||
|
for item in cat:
|
||||||
|
w.addItem(item.name, item.item_id)
|
||||||
|
self._ids.append(item.item_id)
|
||||||
|
if value in self._ids:
|
||||||
|
w.setCurrentIndex(self._ids.index(value))
|
||||||
|
w.currentIndexChanged.connect(
|
||||||
|
lambda idx: self.value_changed.emit(w.itemData(idx)))
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _build_catalogue_multi(self, value) -> QWidget:
|
||||||
|
w = QListWidget()
|
||||||
|
w.setMaximumHeight(90)
|
||||||
|
selected = set(value or [])
|
||||||
|
cat = self.catalogues.get(self.prop.catalogue) if self.prop.catalogue else None
|
||||||
|
if cat is not None:
|
||||||
|
for item in cat:
|
||||||
|
it = QListWidgetItem(item.name)
|
||||||
|
it.setData(Qt.ItemDataRole.UserRole, item.item_id)
|
||||||
|
it.setFlags(it.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||||
|
it.setCheckState(Qt.CheckState.Checked if item.item_id in selected
|
||||||
|
else Qt.CheckState.Unchecked)
|
||||||
|
w.addItem(it)
|
||||||
|
|
||||||
|
def changed(_item):
|
||||||
|
ids = [w.item(i).data(Qt.ItemDataRole.UserRole)
|
||||||
|
for i in range(w.count())
|
||||||
|
if w.item(i).checkState() == Qt.CheckState.Checked]
|
||||||
|
self.value_changed.emit(ids)
|
||||||
|
|
||||||
|
w.itemChanged.connect(changed)
|
||||||
|
return w
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_list(text: str) -> list:
|
||||||
|
out = []
|
||||||
|
for token in text.split(","):
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
out.append(int(token))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
out.append(float(token))
|
||||||
|
except ValueError:
|
||||||
|
out.append(token)
|
||||||
|
return out
|
||||||
117
pipeline_editor/panels/property_panel.py
Normal file
117
pipeline_editor/panels/property_panel.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""Right-dock property panel: grouped, type-aware editors for the selection."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDoubleSpinBox, QFormLayout, QGroupBox, QLabel, QScrollArea, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..model.catalogue import CatalogueRegistry
|
||||||
|
from ..model.edge import EdgeModel
|
||||||
|
from ..model.node import NodeModel
|
||||||
|
from .property_editors import PropertyEditor
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyPanel(QWidget):
|
||||||
|
"""Displays and edits properties of the currently selected node/edge.
|
||||||
|
|
||||||
|
Editing does not mutate the model directly; it emits signals that the
|
||||||
|
controller turns into undoable commands.
|
||||||
|
"""
|
||||||
|
|
||||||
|
property_edited = pyqtSignal(str, str, str, object) # kind, owner_id, key, value
|
||||||
|
geometry_edited = pyqtSignal(str, str, object) # node_id, field, value
|
||||||
|
|
||||||
|
def __init__(self, catalogues: CatalogueRegistry, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.catalogues = catalogues
|
||||||
|
self._kind: Optional[str] = None
|
||||||
|
self._owner_id: Optional[str] = None
|
||||||
|
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._scroll = QScrollArea()
|
||||||
|
self._scroll.setWidgetResizable(True)
|
||||||
|
outer.addWidget(self._scroll)
|
||||||
|
self._placeholder()
|
||||||
|
|
||||||
|
# -- public API -------------------------------------------------------
|
||||||
|
def set_target(self, model) -> None:
|
||||||
|
if isinstance(model, NodeModel):
|
||||||
|
self._build_for_node(model)
|
||||||
|
elif isinstance(model, EdgeModel):
|
||||||
|
self._build_for_edge(model)
|
||||||
|
else:
|
||||||
|
self._placeholder()
|
||||||
|
|
||||||
|
def refresh(self, model) -> None:
|
||||||
|
"""Rebuild if the currently shown owner changed under us."""
|
||||||
|
self.set_target(model)
|
||||||
|
|
||||||
|
# -- builders ---------------------------------------------------------
|
||||||
|
def _new_container(self, title: str) -> QVBoxLayout:
|
||||||
|
body = QWidget()
|
||||||
|
layout = QVBoxLayout(body)
|
||||||
|
layout.setContentsMargins(6, 6, 6, 6)
|
||||||
|
header = QLabel(f"<b>{title}</b>")
|
||||||
|
layout.addWidget(header)
|
||||||
|
self._scroll.setWidget(body)
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def _placeholder(self) -> None:
|
||||||
|
self._kind = None
|
||||||
|
self._owner_id = None
|
||||||
|
body = QWidget()
|
||||||
|
layout = QVBoxLayout(body)
|
||||||
|
layout.addWidget(QLabel("<i>Select a node or edge to edit its properties.</i>"))
|
||||||
|
layout.addStretch(1)
|
||||||
|
self._scroll.setWidget(body)
|
||||||
|
|
||||||
|
def _build_for_node(self, node: NodeModel) -> None:
|
||||||
|
self._kind = "node"
|
||||||
|
self._owner_id = node.node_id
|
||||||
|
layout = self._new_container(f"Node: {node.title or node.template_key}")
|
||||||
|
layout.addWidget(self._geometry_group(node))
|
||||||
|
for group in node.properties:
|
||||||
|
layout.addWidget(self._property_group("node", node.node_id, group))
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
def _build_for_edge(self, edge: EdgeModel) -> None:
|
||||||
|
self._kind = "edge"
|
||||||
|
self._owner_id = edge.edge_id
|
||||||
|
layout = self._new_container(f"Edge: {edge.title or edge.edge_id}")
|
||||||
|
for group in edge.properties:
|
||||||
|
layout.addWidget(self._property_group("edge", edge.edge_id, group))
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
def _geometry_group(self, node: NodeModel) -> QGroupBox:
|
||||||
|
box = QGroupBox("Geometry")
|
||||||
|
form = QFormLayout(box)
|
||||||
|
for field, label, mn, mx in (
|
||||||
|
("x", "X", -1e6, 1e6),
|
||||||
|
("y", "Y", -1e6, 1e6),
|
||||||
|
("width", "Width", 10, 1e4),
|
||||||
|
("height", "Height", 10, 1e4),
|
||||||
|
("rotation", "Rotation", -360, 360),
|
||||||
|
):
|
||||||
|
spin = QDoubleSpinBox()
|
||||||
|
spin.setDecimals(1)
|
||||||
|
spin.setRange(mn, mx)
|
||||||
|
spin.setValue(float(getattr(node, field)))
|
||||||
|
spin.valueChanged.connect(
|
||||||
|
lambda val, f=field: self.geometry_edited.emit(node.node_id, f, val))
|
||||||
|
form.addRow(label, spin)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _property_group(self, kind: str, owner_id: str, group) -> QGroupBox:
|
||||||
|
box = QGroupBox(group.name)
|
||||||
|
form = QFormLayout(box)
|
||||||
|
for prop in group:
|
||||||
|
editor = PropertyEditor(prop, self.catalogues)
|
||||||
|
editor.value_changed.connect(
|
||||||
|
lambda val, k=prop.key: self.property_edited.emit(kind, owner_id, k, val))
|
||||||
|
label = f"{prop.label}"
|
||||||
|
form.addRow(label, editor)
|
||||||
|
return box
|
||||||
29
pipeline_editor/sample.py
Normal file
29
pipeline_editor/sample.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Builds a small demonstration network through the controller."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def build_sample(controller):
|
||||||
|
"""Create a source -> pump -> junction -> consumers water network."""
|
||||||
|
add = controller.add_node
|
||||||
|
connect = controller.connect
|
||||||
|
|
||||||
|
source = add("source", 0, 200)
|
||||||
|
pump = add("pump", 220, 200)
|
||||||
|
tank = add("tank", 420, 60)
|
||||||
|
junction = add("junction", 460, 220)
|
||||||
|
valve = add("valve", 640, 220)
|
||||||
|
c1 = add("consumer", 820, 120)
|
||||||
|
c2 = add("consumer", 820, 320)
|
||||||
|
|
||||||
|
connect(source.node_id, "out", pump.node_id, "in")
|
||||||
|
connect(pump.node_id, "out", junction.node_id, "w")
|
||||||
|
connect(junction.node_id, "n", tank.node_id, "in")
|
||||||
|
connect(junction.node_id, "e", valve.node_id, "in")
|
||||||
|
connect(valve.node_id, "out", c1.node_id, "in")
|
||||||
|
connect(junction.node_id, "s", c2.node_id, "in")
|
||||||
|
|
||||||
|
# give consumers some demand for the flow solver
|
||||||
|
for cons, demand in ((c1, 50), (c2, 45)):
|
||||||
|
cons.properties.set_value("demand", demand)
|
||||||
|
|
||||||
|
return [source, pump, tank, junction, valve, c1, c2]
|
||||||
99
pipeline_editor/view/canvas_view.py
Normal file
99
pipeline_editor/view/canvas_view.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""CanvasView: zoomable/pannable QGraphicsView that accepts node drops."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtGui import QPainter
|
||||||
|
from PyQt6.QtWidgets import QGraphicsView
|
||||||
|
|
||||||
|
from ..panels.node_panel import NODE_MIME
|
||||||
|
|
||||||
|
MIN_SCALE = 0.15
|
||||||
|
MAX_SCALE = 6.0
|
||||||
|
|
||||||
|
|
||||||
|
class CanvasView(QGraphicsView):
|
||||||
|
def __init__(self, scene, parent=None):
|
||||||
|
super().__init__(scene, parent)
|
||||||
|
self.setRenderHints(
|
||||||
|
QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform)
|
||||||
|
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||||
|
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||||
|
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
self._scale = 1.0
|
||||||
|
self._panning = False
|
||||||
|
|
||||||
|
# -- zoom -------------------------------------------------------------
|
||||||
|
def wheelEvent(self, event):
|
||||||
|
factor = 1.2 if event.angleDelta().y() > 0 else 1 / 1.2
|
||||||
|
new_scale = self._scale * factor
|
||||||
|
if new_scale < MIN_SCALE or new_scale > MAX_SCALE:
|
||||||
|
return
|
||||||
|
self._scale = new_scale
|
||||||
|
self.scale(factor, factor)
|
||||||
|
|
||||||
|
def reset_zoom(self):
|
||||||
|
self.resetTransform()
|
||||||
|
self._scale = 1.0
|
||||||
|
|
||||||
|
def fit_to_contents(self):
|
||||||
|
rect = self.scene().itemsBoundingRect()
|
||||||
|
if rect.isNull():
|
||||||
|
return
|
||||||
|
self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.AspectRatioMode.KeepAspectRatio)
|
||||||
|
self._scale = self.transform().m11()
|
||||||
|
|
||||||
|
# -- panning (space or middle mouse) ----------------------------------
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
if event.key() == Qt.Key.Key_Space and not self._panning:
|
||||||
|
self._panning = True
|
||||||
|
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
def keyReleaseEvent(self, event):
|
||||||
|
if event.key() == Qt.Key.Key_Space:
|
||||||
|
self._panning = False
|
||||||
|
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||||
|
super().keyReleaseEvent(event)
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
if event.button() == Qt.MouseButton.MiddleButton:
|
||||||
|
self._panning = True
|
||||||
|
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
||||||
|
# forward as a left press so ScrollHandDrag engages
|
||||||
|
from PyQt6.QtGui import QMouseEvent
|
||||||
|
fake = QMouseEvent(event.type(), event.position(), event.globalPosition(),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||||
|
event.modifiers())
|
||||||
|
super().mousePressEvent(fake)
|
||||||
|
return
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
if event.button() == Qt.MouseButton.MiddleButton and self._panning:
|
||||||
|
self._panning = False
|
||||||
|
self.setDragMode(QGraphicsView.DragMode.RubberBandDrag)
|
||||||
|
return
|
||||||
|
super().mouseReleaseEvent(event)
|
||||||
|
|
||||||
|
# -- drops from the node panel ---------------------------------------
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if event.mimeData().hasFormat(NODE_MIME):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
else:
|
||||||
|
super().dragEnterEvent(event)
|
||||||
|
|
||||||
|
def dragMoveEvent(self, event):
|
||||||
|
if event.mimeData().hasFormat(NODE_MIME):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
else:
|
||||||
|
super().dragMoveEvent(event)
|
||||||
|
|
||||||
|
def dropEvent(self, event):
|
||||||
|
if event.mimeData().hasFormat(NODE_MIME):
|
||||||
|
key = bytes(event.mimeData().data(NODE_MIME)).decode("utf-8")
|
||||||
|
scene_pos = self.mapToScene(event.position().toPoint())
|
||||||
|
self.scene().create_node(key, scene_pos.x(), scene_pos.y())
|
||||||
|
event.acceptProposedAction()
|
||||||
|
else:
|
||||||
|
super().dropEvent(event)
|
||||||
94
tests/test_app.py
Normal file
94
tests/test_app.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""Integration tests for the main window: sample, calc, IO, undo, config."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pipeline_editor.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def win(qtbot):
|
||||||
|
w = MainWindow()
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_builds_network(win):
|
||||||
|
win.load_sample()
|
||||||
|
assert len(list(win.document.nodes())) == 7
|
||||||
|
assert len(list(win.document.edges())) == 6
|
||||||
|
assert len(win.scene.node_items) == 7
|
||||||
|
assert len(win.scene.edge_items) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_flow_sets_edge_flows(win):
|
||||||
|
win.load_sample()
|
||||||
|
win.solve_flow()
|
||||||
|
flows = [e.flow for e in win.document.edges()]
|
||||||
|
assert any(f > 0 for f in flows)
|
||||||
|
# source edge should carry the sum of downstream demand (50 + 45)
|
||||||
|
assert max(flows) == pytest.approx(95)
|
||||||
|
|
||||||
|
|
||||||
|
def test_animation_toggle(win):
|
||||||
|
win.load_sample()
|
||||||
|
win.toggle_flow(True)
|
||||||
|
assert win.animator.running is True
|
||||||
|
win.animator.step()
|
||||||
|
win.toggle_flow(False)
|
||||||
|
assert win.animator.running is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_and_undo_via_controller(win):
|
||||||
|
win.load_sample()
|
||||||
|
node = next(win.document.nodes())
|
||||||
|
win.scene.node_items[node.node_id].setSelected(True)
|
||||||
|
n_before = len(list(win.document.nodes()))
|
||||||
|
win.delete_selection()
|
||||||
|
assert len(list(win.document.nodes())) == n_before - 1
|
||||||
|
win.controller.undo()
|
||||||
|
assert len(list(win.document.nodes())) == n_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_selection(win):
|
||||||
|
win.load_sample()
|
||||||
|
node = next(win.document.nodes())
|
||||||
|
win.scene.node_items[node.node_id].setSelected(True)
|
||||||
|
n_before = len(list(win.document.nodes()))
|
||||||
|
win.duplicate_selection()
|
||||||
|
assert len(list(win.document.nodes())) == n_before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_open_roundtrip(win, tmp_path):
|
||||||
|
win.load_sample()
|
||||||
|
path = tmp_path / "diagram.pipe"
|
||||||
|
win._current_path = str(path)
|
||||||
|
win.save_document()
|
||||||
|
assert path.exists()
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
assert len(data["nodes"]) == 7
|
||||||
|
|
||||||
|
# open into a fresh window
|
||||||
|
win2 = MainWindow()
|
||||||
|
with open(path) as fh:
|
||||||
|
win2.document.load_dict(json.load(fh))
|
||||||
|
assert len(list(win2.document.nodes())) == 7
|
||||||
|
assert len(win2.scene.node_items) == 7 # scene rebuilt from 'cleared' signal
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_change_applies_scheme(win):
|
||||||
|
win.load_sample()
|
||||||
|
win.config.color_scheme = "high_contrast"
|
||||||
|
win.config.emit_changed()
|
||||||
|
assert win.document.relations.active_scheme == "high_contrast"
|
||||||
|
assert win.document.relations.color("water") == "#0000ff"
|
||||||
|
|
||||||
|
|
||||||
|
def test_property_edit_through_panel_is_undoable(win):
|
||||||
|
win.load_sample()
|
||||||
|
node = next(n for n in win.document.nodes() if n.template_key == "consumer")
|
||||||
|
win.scene.node_items[node.node_id].setSelected(True)
|
||||||
|
win._on_property_edited("node", node.node_id, "demand", 123.0)
|
||||||
|
assert node.properties.value("demand") == 123.0
|
||||||
|
win.controller.undo()
|
||||||
|
assert node.properties.value("demand") != 123.0
|
||||||
102
tests/test_panels.py
Normal file
102
tests/test_panels.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""Tests for property editors, property panel and node panel."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pipeline_editor.model.catalogue import default_registry as default_catalogues
|
||||||
|
from pipeline_editor.model.node_library import default_library
|
||||||
|
from pipeline_editor.model.properties import Property, PropertyType
|
||||||
|
from pipeline_editor.panels.property_editors import PropertyEditor
|
||||||
|
from pipeline_editor.panels.property_panel import PropertyPanel
|
||||||
|
from pipeline_editor.panels.node_panel import NodePanel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cats():
|
||||||
|
return default_catalogues()
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_capture(editor):
|
||||||
|
out = []
|
||||||
|
editor.value_changed.connect(lambda v: out.append(v))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_editor(qtbot, cats):
|
||||||
|
ed = PropertyEditor(Property("s", "S", PropertyType.STRING, "hi"), cats)
|
||||||
|
out = _emit_capture(ed)
|
||||||
|
ed._control.setText("bye")
|
||||||
|
ed._control.editingFinished.emit()
|
||||||
|
assert out == ["bye"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_float_editor_with_bounds(qtbot, cats):
|
||||||
|
ed = PropertyEditor(Property("f", "F", PropertyType.FLOAT, 5.0, minimum=0, maximum=10), cats)
|
||||||
|
out = _emit_capture(ed)
|
||||||
|
ed._control.setValue(7.5)
|
||||||
|
assert out[-1] == 7.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_editor(qtbot, cats):
|
||||||
|
ed = PropertyEditor(Property("e", "E", PropertyType.ENUM, "a", options=["a", "b", "c"]), cats)
|
||||||
|
out = _emit_capture(ed)
|
||||||
|
ed._control.setCurrentText("c")
|
||||||
|
assert out[-1] == "c"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bool_editor(qtbot, cats):
|
||||||
|
ed = PropertyEditor(Property("b", "B", PropertyType.BOOL, False), cats)
|
||||||
|
out = _emit_capture(ed)
|
||||||
|
ed._control.setChecked(True)
|
||||||
|
assert out[-1] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogue_single_editor(qtbot, cats):
|
||||||
|
ed = PropertyEditor(
|
||||||
|
Property("d", "Dia", PropertyType.CATALOGUE_ITEM, "DN50", catalogue="pipe_classes"), cats)
|
||||||
|
out = _emit_capture(ed)
|
||||||
|
# switch to a different catalogue entry
|
||||||
|
ed._control.setCurrentIndex(1)
|
||||||
|
assert out[-1] in {"DN50", "DN100", "DN200", "DN300"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_list_parsing(qtbot, cats):
|
||||||
|
ed = PropertyEditor(Property("vl", "VL", PropertyType.VALUE_LIST, [1, 2]), cats)
|
||||||
|
out = _emit_capture(ed)
|
||||||
|
ed._control.setText("3, 4, 5")
|
||||||
|
ed._control.editingFinished.emit()
|
||||||
|
assert out[-1] == [3, 4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_property_panel_builds_and_emits(qtbot, cats):
|
||||||
|
lib = default_library()
|
||||||
|
node = lib.get("pump").instantiate("n1", 10, 20)
|
||||||
|
panel = PropertyPanel(cats)
|
||||||
|
panel.set_target(node)
|
||||||
|
assert panel._kind == "node" and panel._owner_id == "n1"
|
||||||
|
|
||||||
|
edits = []
|
||||||
|
panel.property_edited.connect(lambda *a: edits.append(a))
|
||||||
|
geoms = []
|
||||||
|
panel.geometry_edited.connect(lambda *a: geoms.append(a))
|
||||||
|
# re-emit through a fresh editor to simulate user interaction
|
||||||
|
panel.property_edited.emit("node", "n1", "head", 42.0)
|
||||||
|
panel.geometry_edited.emit("n1", "x", 99.0)
|
||||||
|
assert ("node", "n1", "head", 42.0) in edits
|
||||||
|
assert ("n1", "x", 99.0) in geoms
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_panel_populates(qtbot):
|
||||||
|
panel = NodePanel(default_library())
|
||||||
|
tree = panel._tree
|
||||||
|
groups = [tree.topLevelItem(i).text(0) for i in range(tree.topLevelItemCount())]
|
||||||
|
assert "Equipment" in groups
|
||||||
|
# filtering narrows leaves
|
||||||
|
panel._search.setText("pump")
|
||||||
|
# at least one visible leaf named pump
|
||||||
|
found = False
|
||||||
|
for i in range(tree.topLevelItemCount()):
|
||||||
|
g = tree.topLevelItem(i)
|
||||||
|
for j in range(g.childCount()):
|
||||||
|
leaf = g.child(j)
|
||||||
|
if not leaf.isHidden() and "pump" in leaf.text(0).lower():
|
||||||
|
found = True
|
||||||
|
assert found
|
||||||
Loading…
x
Reference in New Issue
Block a user