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:
Ilya 2026-07-02 23:25:14 +02:00
parent af66debcde
commit ae3f752b6f
13 changed files with 1235 additions and 0 deletions

View File

@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
raise SystemExit(main())

22
pipeline_editor/app.py Normal file
View 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())

View 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)

View File

@ -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()

View 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()

View 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()

View 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)

View 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

View 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
View 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]

View 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
View 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
View 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