Ilya 35a465f088 feat(edit): clipboard copy/paste of nodes
Add an internal clipboard on the controller with undoable macro paste, and
Copy/Paste menu actions and shortcuts in the main window. Covered by a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:26:52 +02:00

343 lines
14 KiB
Python

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