From ae3f752b6f624f643508822f3d59c17a8dac79a6 Mon Sep 17 00:00:00 2001 From: Ilya Date: Thu, 2 Jul 2026 23:25:14 +0200 Subject: [PATCH] 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) --- pipeline_editor/__main__.py | 4 + pipeline_editor/app.py | 22 ++ pipeline_editor/calc/flow_animator.py | 46 +++ pipeline_editor/controller.py | 54 ++++ pipeline_editor/main_window.py | 329 +++++++++++++++++++++ pipeline_editor/panels/config_dialog.py | 78 +++++ pipeline_editor/panels/node_panel.py | 100 +++++++ pipeline_editor/panels/property_editors.py | 161 ++++++++++ pipeline_editor/panels/property_panel.py | 117 ++++++++ pipeline_editor/sample.py | 29 ++ pipeline_editor/view/canvas_view.py | 99 +++++++ tests/test_app.py | 94 ++++++ tests/test_panels.py | 102 +++++++ 13 files changed, 1235 insertions(+) create mode 100644 pipeline_editor/__main__.py create mode 100644 pipeline_editor/app.py create mode 100644 pipeline_editor/calc/flow_animator.py create mode 100644 pipeline_editor/main_window.py create mode 100644 pipeline_editor/panels/config_dialog.py create mode 100644 pipeline_editor/panels/node_panel.py create mode 100644 pipeline_editor/panels/property_editors.py create mode 100644 pipeline_editor/panels/property_panel.py create mode 100644 pipeline_editor/sample.py create mode 100644 pipeline_editor/view/canvas_view.py create mode 100644 tests/test_app.py create mode 100644 tests/test_panels.py 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