fbce1ecb42
3 editores nuevos integrados como tabs en MainWindow. vmssailor/studio/editors/symbols.py - 8 simbolos navales: motor, pump, valve, tank, sensor, indicator, line, label - _BaseSymbol QGraphicsItemGroup arrastrable + seleccionable - TankSymbol con fill animable por porcentaje - SymbolSpec dataclass serializable a JSON vmssailor/studio/editors/mimic_editor.py - Paleta lateral con tipos de simbolos - Doble-click crea simbolo en canvas - Selector de sistema (de los habilitados en el proyecto) - Boton 'Cargar demo' inserta P&ID demo (tanque-valvula-bomba-motor-sensor) - serialize_to_project() para guardar en .vmsproj vmssailor/studio/editors/tag_editor.py - Tabla CRUD con 13 columnas - Edicion inline de description, unit_si, range, control_mode - Validacion via Pydantic en cada edit - Filtro por texto en vivo - Contador de tags vmssailor/studio/editors/alarm_editor.py - Tabla aplanada de todas las alarmas configuradas - Counts por prioridad coloreados - Dialog modal para agregar AlarmConfig nueva - Edicion inline de threshold, operator, priority, hysteresis, delay, message - Eliminacion individual vmssailor/studio/main_window.py - Tabs 'Equipos', 'Mimicos', 'Tags', 'Alarmas' en panel derecho - Todos los editores reciben set_project y emiten projectMutated Tests (tests/studio/test_editors.py, 6 nuevos, total 126/126): - Symbol factory para los 8 tipos - MimicEditor con demo - TagEditor render - AlarmEditor priority counts + empty state - MainWindow tabs presentes 126/126 verde, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
9.3 KiB
Python
274 lines
9.3 KiB
Python
"""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]] = {}
|
|
|
|
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:
|
|
sys = self._current_system()
|
|
if sys is None:
|
|
return
|
|
kind = item.data(Qt.ItemDataRole.UserRole)
|
|
new_spec = SymbolSpec(kind=kind, x=100, y=100, label="")
|
|
self._mimics.setdefault(sys, []).append(new_spec)
|
|
self._render_current()
|
|
self.mimicChanged.emit()
|
|
|
|
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._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._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)
|