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:
2026-05-17 18:34:30 -04:00
parent 6ad76a89fa
commit fbce1ecb42
7 changed files with 1233 additions and 4 deletions
+273
View File
@@ -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)