"""Editor visual de mímicos por sistema. Sprint 3: canvas QGraphicsView con paleta lateral de símbolos arrastrables. Cada mímico se asocia a un SystemId del proyecto. La serialización JSON se guarda en `Project.notes` por simplicidad en Sprint 3 (más adelante se agrega a `.vmsproj` con tabla propia). """ from __future__ import annotations import json from typing import Any from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QBrush, QColor, QPainter, QPen from PySide6.QtWidgets import ( QComboBox, QFrame, QGraphicsScene, QGraphicsView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget, ) from vmssailor.core.project import Project from vmssailor.studio.editors.symbols import ( SYMBOL_KINDS, SymbolSpec, make_symbol, ) from vmssailor.studio.theme import ( C_ABYSS, C_CYAN, C_FOG, C_STEEL, mono_font, ui_font, ) _SYMBOL_LABELS = { "motor": "Motor (M)", "pump": "Bomba", "valve": "Válvula", "tank": "Tanque", "sensor": "Sensor (T/P/L)", "indicator": "Indicador valor", "line": "Línea proceso", "label": "Etiqueta", } class MimicEditor(QWidget): """Editor de mímicos. Layout: paleta | canvas | propiedades.""" mimicChanged = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._project: Project | None = None # Almacén in-memory de mímicos por sistema: # { system_id_value : [SymbolSpec, ...] } self._mimics: dict[str, list[SymbolSpec]] = {} self._empty_state_active: bool = True outer = QVBoxLayout(self) outer.setContentsMargins(12, 12, 12, 12) outer.setSpacing(10) # System selector + actions top_row = QHBoxLayout() top_row.addWidget(QLabel("Sistema:")) self._system_combo = QComboBox() self._system_combo.setMinimumWidth(220) top_row.addWidget(self._system_combo) self._btn_clear = QPushButton("Limpiar mímico") self._btn_add_demo = QPushButton("Cargar demo") top_row.addWidget(self._btn_clear) top_row.addWidget(self._btn_add_demo) top_row.addStretch(1) self._symbol_count = QLabel("0 símbolos") self._symbol_count.setFont(mono_font(10)) self._symbol_count.setStyleSheet(f"color: {C_CYAN};") top_row.addWidget(self._symbol_count) outer.addLayout(top_row) # Splitter: palette | canvas splitter = QSplitter(Qt.Horizontal) outer.addWidget(splitter, 1) # Palette palette = QListWidget() palette.setMinimumWidth(180) palette.setMaximumWidth(220) for kind in SYMBOL_KINDS: item = QListWidgetItem(_SYMBOL_LABELS.get(kind, kind)) item.setData(Qt.ItemDataRole.UserRole, kind) palette.addItem(item) palette.itemDoubleClicked.connect(self._on_palette_double_click) splitter.addWidget(palette) self._palette = palette # Canvas self._scene = QGraphicsScene(self) self._view = QGraphicsView(self._scene) self._view.setFrameShape(QFrame.Shape.NoFrame) self._view.setRenderHint(QPainter.RenderHint.Antialiasing) self._view.setBackgroundBrush(QBrush(QColor(C_ABYSS))) self._view.setDragMode(QGraphicsView.DragMode.RubberBandDrag) splitter.addWidget(self._view) splitter.setSizes([200, 700]) # Signals self._system_combo.currentIndexChanged.connect(self._on_system_changed) self._btn_clear.clicked.connect(self._on_clear) self._btn_add_demo.clicked.connect(self._on_add_demo) self._draw_empty_state("Selecciona un proyecto y un sistema.") # ----- Public API ------------------------------------------------- def set_project(self, project: Project | None) -> None: self._project = project # Reload mimics from project.notes if previously serialized self._mimics = self._deserialize_mimics(project) self._refresh_system_combo() self._render_current() def serialize_to_project(self) -> dict[str, Any]: """Devuelve la representación JSON-friendly para guardar en .vmsproj.""" return { sys: [ { "kind": s.kind, "x": s.x, "y": s.y, "width": s.width, "height": s.height, "label": s.label, "tag_ref": s.tag_ref, "rotation_deg": s.rotation_deg, "extra": s.extra, } for s in items ] for sys, items in self._mimics.items() } # ----- Internals -------------------------------------------------- def _deserialize_mimics(self, project: Project | None) -> dict[str, list[SymbolSpec]]: if project is None or not project.notes: return {} try: # Convención Sprint 3: notes puede contener bloque JSON con prefijo notes = project.notes idx = notes.find("__MIMICS_JSON__:") if idx < 0: return {} blob = notes[idx + len("__MIMICS_JSON__:"):].strip() data = json.loads(blob) out: dict[str, list[SymbolSpec]] = {} for sys, items in data.items(): out[sys] = [SymbolSpec(**i) for i in items] return out except Exception: return {} def _refresh_system_combo(self) -> None: self._system_combo.blockSignals(True) self._system_combo.clear() if self._project is None: self._system_combo.addItem("(sin proyecto)", None) else: for sid in self._project.systems_enabled: self._system_combo.addItem(sid.value, sid.value) if self._system_combo.count() == 0: self._system_combo.addItem("(sin sistemas habilitados)", None) self._system_combo.blockSignals(False) def _current_system(self) -> str | None: return self._system_combo.currentData() def _on_system_changed(self) -> None: self._render_current() def _on_clear(self) -> None: sys = self._current_system() if sys is None: return self._mimics[sys] = [] self._render_current() self.mimicChanged.emit() def _on_add_demo(self) -> None: sys = self._current_system() if sys is None: return # Demo simple: 1 tanque + 1 bomba + 1 motor + 1 línea + 1 indicator demo = [ SymbolSpec(kind="tank", x=40, y=80, width=80, height=140, label="TANK_1"), SymbolSpec(kind="line", x=120, y=140, width=80, height=20), SymbolSpec(kind="valve", x=200, y=120, width=40, height=40, label="V1"), SymbolSpec(kind="line", x=240, y=140, width=80, height=20), SymbolSpec(kind="pump", x=320, y=120, width=60, height=60, label="PUMP_1"), SymbolSpec(kind="line", x=380, y=140, width=120, height=20), SymbolSpec(kind="motor", x=520, y=110, width=80, height=80, label="ME_PORT"), SymbolSpec(kind="sensor", x=620, y=80, width=40, height=40, label="T", extra={"letter": "T"}), SymbolSpec( kind="indicator", x=620, y=140, width=80, height=30, label="rpm", extra={"value": "1520"}, ), ] self._mimics[sys] = demo self._render_current() self.mimicChanged.emit() def _on_palette_double_click(self, item: QListWidgetItem) -> None: """Agrega un símbolo nuevo SIN reconstruir la escena. Si ya hay símbolos arrastrados por el usuario, conservan posición. El nuevo se coloca en el siguiente slot libre de una grilla 6 × N. """ sys = self._current_system() if sys is None: return kind = item.data(Qt.ItemDataRole.UserRole) existing = self._mimics.setdefault(sys, []) x, y = self._next_free_position(existing) new_spec = SymbolSpec(kind=kind, x=x, y=y, label="") existing.append(new_spec) if self._empty_state_active: # En empty state hay un label central; render completo para limpiarlo self._render_current() else: # Solo agrega el nuevo item — preserva posiciones arrastradas new_item = make_symbol(new_spec) self._scene.addItem(new_item) new_item.setSelected(True) self._symbol_count.setText(f"{len(existing)} símbolos") self.mimicChanged.emit() def _next_free_position(self, specs: list[SymbolSpec]) -> tuple[float, float]: """Devuelve (x, y) en una grilla 6 × N evitando solapamientos.""" col_w, row_h = 110, 90 cols = 6 used = set() for s in specs: col = int(round((s.x - 40) / col_w)) row = int(round((s.y - 40) / row_h)) used.add((col, row)) for row in range(30): for col in range(cols): if (col, row) not in used: return 40.0 + col * col_w, 40.0 + row * row_h return 40.0, 40.0 def _render_current(self) -> None: sys = self._current_system() self._scene.clear() if sys is None: self._draw_empty_state("Sin sistema seleccionado.") return specs = self._mimics.get(sys, []) if not specs: self._draw_empty_state( f"Mímico vacío para sistema '{sys}'.\n" "Doble-click en un símbolo de la paleta para agregar, o pulsa Cargar demo." ) self._symbol_count.setText("0 símbolos") return self._empty_state_active = False self._draw_grid() for spec in specs: self._scene.addItem(make_symbol(spec)) self._symbol_count.setText(f"{len(specs)} símbolos") def _draw_empty_state(self, msg: str) -> None: self._empty_state_active = True self._draw_grid() text = self._scene.addText(msg, ui_font(11)) text.setDefaultTextColor(QColor(C_FOG)) rect = text.boundingRect() text.setPos(-rect.width() / 2 + 360, -rect.height() / 2 + 220) def _draw_grid(self) -> None: self._scene.setSceneRect(0, 0, 800, 480) pen = QPen(QColor(C_STEEL)) pen.setCosmetic(True) pen.setWidthF(0.5) for x in range(0, 801, 40): self._scene.addLine(x, 0, x, 480, pen) for y in range(0, 481, 40): self._scene.addLine(0, y, 800, y, pen)