sprint-3: editores de mimicos + tags + alarmas
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>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user