"""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_copy = QAction("&Copy", self, shortcut=QKeySequence.StandardKey.Copy, triggered=self.copy_selection) self.act_paste = QAction("&Paste", self, shortcut=QKeySequence.StandardKey.Paste, triggered=self.paste_clipboard) 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_copy, self.act_paste, self.act_dup, self.act_delete]) 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)) def copy_selection(self) -> None: node_ids, _ = self._selected_ids() n = self.controller.copy(node_ids) self.statusBar().showMessage(f"Copied {n} node(s)") def paste_clipboard(self) -> None: g = self.config.grid_size self.controller.paste(offset=(g, g)) # -- 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()