diff --git a/pipeline_editor/__main__.py b/pipeline_editor/__main__.py
new file mode 100644
index 0000000..c58ddac
--- /dev/null
+++ b/pipeline_editor/__main__.py
@@ -0,0 +1,4 @@
+from .app import main
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/pipeline_editor/app.py b/pipeline_editor/app.py
new file mode 100644
index 0000000..8910b24
--- /dev/null
+++ b/pipeline_editor/app.py
@@ -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())
diff --git a/pipeline_editor/calc/flow_animator.py b/pipeline_editor/calc/flow_animator.py
new file mode 100644
index 0000000..db2f710
--- /dev/null
+++ b/pipeline_editor/calc/flow_animator.py
@@ -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)
diff --git a/pipeline_editor/controller.py b/pipeline_editor/controller.py
index ced793b..c152e78 100644
--- a/pipeline_editor/controller.py
+++ b/pipeline_editor/controller.py
@@ -105,6 +105,32 @@ class MoveNodeCommand(QUndoCommand):
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):
def __init__(self, doc: DiagramDocument, kind: str, owner_id: str,
key: str, old, new):
@@ -166,6 +192,25 @@ class EditorController:
return
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:
if old == new:
return
@@ -184,6 +229,15 @@ class EditorController:
return
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
def undo(self):
self.undo_stack.undo()
diff --git a/pipeline_editor/main_window.py b/pipeline_editor/main_window.py
new file mode 100644
index 0000000..8244bfd
--- /dev/null
+++ b/pipeline_editor/main_window.py
@@ -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()
diff --git a/pipeline_editor/panels/config_dialog.py b/pipeline_editor/panels/config_dialog.py
new file mode 100644
index 0000000..9a7a492
--- /dev/null
+++ b/pipeline_editor/panels/config_dialog.py
@@ -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()
diff --git a/pipeline_editor/panels/node_panel.py b/pipeline_editor/panels/node_panel.py
new file mode 100644
index 0000000..8a680bc
--- /dev/null
+++ b/pipeline_editor/panels/node_panel.py
@@ -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)
diff --git a/pipeline_editor/panels/property_editors.py b/pipeline_editor/panels/property_editors.py
new file mode 100644
index 0000000..982a38f
--- /dev/null
+++ b/pipeline_editor/panels/property_editors.py
@@ -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
diff --git a/pipeline_editor/panels/property_panel.py b/pipeline_editor/panels/property_panel.py
new file mode 100644
index 0000000..2f39c12
--- /dev/null
+++ b/pipeline_editor/panels/property_panel.py
@@ -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"{title}")
+ 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("Select a node or edge to edit its properties."))
+ 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
diff --git a/pipeline_editor/sample.py b/pipeline_editor/sample.py
new file mode 100644
index 0000000..a24e17c
--- /dev/null
+++ b/pipeline_editor/sample.py
@@ -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]
diff --git a/pipeline_editor/view/canvas_view.py b/pipeline_editor/view/canvas_view.py
new file mode 100644
index 0000000..e431c06
--- /dev/null
+++ b/pipeline_editor/view/canvas_view.py
@@ -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)
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..9f46910
--- /dev/null
+++ b/tests/test_app.py
@@ -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
diff --git a/tests/test_panels.py b/tests/test_panels.py
new file mode 100644
index 0000000..b129a03
--- /dev/null
+++ b/tests/test_panels.py
@@ -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