From fbce1ecb42f95b2ce5308ec9e5757f5d664e2db3 Mon Sep 17 00:00:00 2001 From: Aerom Date: Sun, 17 May 2026 18:34:30 -0400 Subject: [PATCH] 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) --- tests/studio/test_editors.py | 67 +++++ vmssailor/studio/editors/__init__.py | 5 +- vmssailor/studio/editors/alarm_editor.py | 346 +++++++++++++++++++++++ vmssailor/studio/editors/mimic_editor.py | 273 ++++++++++++++++++ vmssailor/studio/editors/symbols.py | 298 +++++++++++++++++++ vmssailor/studio/editors/tag_editor.py | 224 +++++++++++++++ vmssailor/studio/main_window.py | 24 +- 7 files changed, 1233 insertions(+), 4 deletions(-) create mode 100644 tests/studio/test_editors.py create mode 100644 vmssailor/studio/editors/alarm_editor.py create mode 100644 vmssailor/studio/editors/mimic_editor.py create mode 100644 vmssailor/studio/editors/symbols.py create mode 100644 vmssailor/studio/editors/tag_editor.py diff --git a/tests/studio/test_editors.py b/tests/studio/test_editors.py new file mode 100644 index 0000000..5b8ae2b --- /dev/null +++ b/tests/studio/test_editors.py @@ -0,0 +1,67 @@ +"""Tests Sprint 3: editores de mímicos, tags, alarmas + símbolos.""" + +from __future__ import annotations + + +def test_mimic_symbols_factory(qtbot): + from vmssailor.studio.editors.symbols import SYMBOL_KINDS, SymbolSpec, make_symbol + + for kind in SYMBOL_KINDS: + spec = SymbolSpec(kind=kind, x=0, y=0, width=60, height=60, label="X") + item = make_symbol(spec) + assert item is not None + + +def test_mimic_editor_demo_loads(qtbot, sample_project): + from vmssailor.studio.editors.mimic_editor import MimicEditor + + me = MimicEditor() + qtbot.addWidget(me) + me.set_project(sample_project) + assert me._system_combo.count() >= 1 + # Activar primer sistema y cargar demo + me._system_combo.setCurrentIndex(0) + me._on_add_demo() + serialized = me.serialize_to_project() + assert len(serialized) >= 1 + first_sys = next(iter(serialized)) + assert len(serialized[first_sys]) >= 5 # demo trae 9 símbolos + + +def test_tag_editor_renders(qtbot, sample_project): + from vmssailor.studio.editors.tag_editor import TagEditor + + te = TagEditor() + qtbot.addWidget(te) + te.set_project(sample_project) + assert te._table.rowCount() == len(sample_project.tags) + + +def test_alarm_editor_counts_priorities(qtbot, sample_project): + from vmssailor.studio.editors.alarm_editor import AlarmEditor + + ae = AlarmEditor() + qtbot.addWidget(ae) + ae.set_project(sample_project) + # sample_project tiene 1 alarma EMERGENCY en sample_tag + total = sum(len(t.alarms) for t in sample_project.tags) + assert ae._table.rowCount() == total + + +def test_alarm_editor_handles_empty(qtbot): + from vmssailor.studio.editors.alarm_editor import AlarmEditor + + ae = AlarmEditor() + qtbot.addWidget(ae) + ae.set_project(None) + assert ae._table.rowCount() == 0 + + +def test_main_window_includes_tabs(qtbot): + from vmssailor.studio.main_window import MainWindow + + w = MainWindow() + qtbot.addWidget(w) + assert w._tabs.count() == 4 + titles = [w._tabs.tabText(i) for i in range(w._tabs.count())] + assert titles == ["Equipos", "Mímicos", "Tags", "Alarmas"] diff --git a/vmssailor/studio/editors/__init__.py b/vmssailor/studio/editors/__init__.py index 0571a45..5b174ee 100644 --- a/vmssailor/studio/editors/__init__.py +++ b/vmssailor/studio/editors/__init__.py @@ -1,5 +1,8 @@ """Editores del Studio (post-wizard). Sprint 2: equipos. Sprint 3: mímicos, tags, alarmas.""" +from vmssailor.studio.editors.alarm_editor import AlarmEditor from vmssailor.studio.editors.equipment_editor import EquipmentEditor +from vmssailor.studio.editors.mimic_editor import MimicEditor +from vmssailor.studio.editors.tag_editor import TagEditor -__all__ = ["EquipmentEditor"] +__all__ = ["AlarmEditor", "EquipmentEditor", "MimicEditor", "TagEditor"] diff --git a/vmssailor/studio/editors/alarm_editor.py b/vmssailor/studio/editors/alarm_editor.py new file mode 100644 index 0000000..9bf8f7a --- /dev/null +++ b/vmssailor/studio/editors/alarm_editor.py @@ -0,0 +1,346 @@ +"""Editor de configuración de alarmas (Sprint 3). + +Cada tag puede tener múltiples AlarmConfig. Este editor permite: +- Listar todas las alarmas configuradas (todos los tags) +- Editar threshold, operator, priority, hysteresis, delay, message +- Agregar / eliminar alarmas + +La lógica del alarm engine (evaluación en tiempo real) llega en Sprint 4 +con el Runtime servidor. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from vmssailor.core.enums import AlarmPriority +from vmssailor.core.project import Project +from vmssailor.core.tag import AlarmConfig +from vmssailor.shared.ids import make_alarm_config_id +from vmssailor.studio.theme import ( + C_CYAN, + C_EMERGENCY, + C_FOG, + C_HIGH, + C_INFO, + C_WARN, + mono_font, + ui_font, +) + +_PRIORITY_COLORS = { + AlarmPriority.EMERGENCY: C_EMERGENCY, + AlarmPriority.HIGH: C_HIGH, + AlarmPriority.LOW: C_WARN, + AlarmPriority.INFO: C_INFO, +} + +_COLS = [ + "Tag", + "Alarm ID", + "Operador", + "Threshold", + "Prioridad", + "Histéresis", + "Delay (s)", + "Mensaje", +] + + +class AlarmEditor(QWidget): + projectMutated = Signal(Project) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._project: Project | None = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(10) + + header = QLabel("Editor de alarmas") + header.setObjectName("title") + header.setFont(ui_font(14)) + layout.addWidget(header) + + intro = QLabel( + "Configuración declarativa de alarmas. Cuatro prioridades: " + "EMERGENCY (rojo, accion inmediata), HIGH (naranja), LOW (amber, watch), " + "INFO (cyan, registro)." + ) + intro.setStyleSheet(f"color: {C_FOG};") + intro.setFont(ui_font(10)) + intro.setWordWrap(True) + layout.addWidget(intro) + + # Buttons row + btn_row = QHBoxLayout() + self._btn_add = QPushButton("+ Agregar alarma") + self._btn_add.setObjectName("primary") + self._btn_remove = QPushButton("Eliminar seleccionada") + self._counts_by_priority = QLabel("") + self._counts_by_priority.setFont(mono_font(10)) + btn_row.addWidget(self._btn_add) + btn_row.addWidget(self._btn_remove) + btn_row.addStretch(1) + btn_row.addWidget(self._counts_by_priority) + layout.addLayout(btn_row) + + # Table + self._table = QTableWidget(0, len(_COLS)) + self._table.setHorizontalHeaderLabels(_COLS) + self._table.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.Stretch) + self._table.verticalHeader().setVisible(False) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + ) + self._table.itemChanged.connect(self._on_item_changed) + layout.addWidget(self._table, 1) + + self._btn_add.clicked.connect(self._on_add) + self._btn_remove.clicked.connect(self._on_remove) + + # ----- Public API ------------------------------------------------- + + def set_project(self, project: Project | None) -> None: + self._project = project + self._refresh_table() + + # ----- Internals -------------------------------------------------- + + def _refresh_table(self) -> None: + self._table.blockSignals(True) + self._table.setRowCount(0) + if self._project is None: + self._counts_by_priority.setText("") + self._table.blockSignals(False) + return + # Aplanar todas las alarmas + flat: list[tuple[int, str, AlarmConfig]] = [] + for tag_idx, t in enumerate(self._project.tags): + for a in t.alarms: + flat.append((tag_idx, t.id, a)) + self._flat = flat + for _tag_idx, tag_id, a in flat: + self._append_row(tag_id, a) + self._update_priority_counts() + self._table.blockSignals(False) + + def _append_row(self, tag_id: str, a: AlarmConfig) -> None: + row = self._table.rowCount() + self._table.insertRow(row) + tag_item = QTableWidgetItem(tag_id) + tag_item.setFlags(tag_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + tag_item.setForeground(QColor(C_CYAN)) + self._table.setItem(row, 0, tag_item) + id_item = QTableWidgetItem(a.id) + id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + self._table.setItem(row, 1, id_item) + self._table.setItem(row, 2, QTableWidgetItem(a.operator)) + self._table.setItem(row, 3, QTableWidgetItem(f"{a.threshold:.3f}")) + prio = QTableWidgetItem(a.priority.value) + prio.setForeground(QColor(_PRIORITY_COLORS[a.priority])) + self._table.setItem(row, 4, prio) + self._table.setItem(row, 5, QTableWidgetItem(f"{a.hysteresis:.3f}")) + self._table.setItem(row, 6, QTableWidgetItem(f"{a.delay_seconds:.1f}")) + self._table.setItem(row, 7, QTableWidgetItem(a.message)) + + def _update_priority_counts(self) -> None: + if not self._project: + return + counts: dict[AlarmPriority, int] = dict.fromkeys(AlarmPriority, 0) + for t in self._project.tags: + for a in t.alarms: + counts[a.priority] += 1 + parts = [] + for p in (AlarmPriority.EMERGENCY, AlarmPriority.HIGH, AlarmPriority.LOW, AlarmPriority.INFO): + color = _PRIORITY_COLORS[p] + parts.append(f'{p.value}:{counts[p]}') + self._counts_by_priority.setText(" ".join(parts)) + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + if self._project is None: + return + row = item.row() + if row >= len(self._flat): + return + tag_idx, _tag_id, a = self._flat[row] + col = item.column() + new_alarm: AlarmConfig | None = None + try: + if col == 2: + new_alarm = a.model_copy(update={"operator": item.text()}) + elif col == 3: + new_alarm = a.model_copy(update={"threshold": float(item.text())}) + elif col == 4: + new_alarm = a.model_copy(update={"priority": AlarmPriority(item.text())}) + elif col == 5: + new_alarm = a.model_copy(update={"hysteresis": float(item.text())}) + elif col == 6: + new_alarm = a.model_copy(update={"delay_seconds": float(item.text())}) + elif col == 7: + new_alarm = a.model_copy(update={"message": item.text()}) + except Exception as exc: + QMessageBox.warning(self, "Edición inválida", str(exc)) + self._refresh_table() + return + if new_alarm is None: + return + + tag = self._project.tags[tag_idx] + # Reemplazo del alarmConfig dentro de la lista + new_alarms = [new_alarm if x.id == a.id else x for x in tag.alarms] + new_tag = tag.model_copy(update={"alarms": new_alarms}) + new_tags = list(self._project.tags) + new_tags[tag_idx] = new_tag + try: + self._project = self._project.model_copy(update={"tags": new_tags}) + self._project = self._project.__class__.model_validate(self._project.model_dump()) + except Exception as exc: + QMessageBox.critical(self, "Validación falló", str(exc)) + self._refresh_table() + return + self._project.touch() + self.projectMutated.emit(self._project) + self._update_priority_counts() + + def _on_add(self) -> None: + if self._project is None or not self._project.tags: + QMessageBox.information(self, "Agregar alarma", "No hay tags en el proyecto.") + return + dlg = _AddAlarmDialog(self._project, self) + if dlg.exec(): + tag_idx, alarm = dlg.build() + if alarm is None: + return + tag = self._project.tags[tag_idx] + new_tag = tag.model_copy(update={"alarms": [*tag.alarms, alarm]}) + new_tags = list(self._project.tags) + new_tags[tag_idx] = new_tag + try: + self._project = self._project.model_copy(update={"tags": new_tags}) + self._project = self._project.__class__.model_validate( + self._project.model_dump() + ) + except Exception as exc: + QMessageBox.critical(self, "Validación falló", str(exc)) + return + self._project.touch() + self.projectMutated.emit(self._project) + self._refresh_table() + + def _on_remove(self) -> None: + if self._project is None: + return + row = self._table.currentRow() + if row < 0 or row >= len(self._flat): + return + tag_idx, _tag_id, a = self._flat[row] + tag = self._project.tags[tag_idx] + new_tag = tag.model_copy(update={"alarms": [x for x in tag.alarms if x.id != a.id]}) + new_tags = list(self._project.tags) + new_tags[tag_idx] = new_tag + try: + self._project = self._project.model_copy(update={"tags": new_tags}) + self._project = self._project.__class__.model_validate(self._project.model_dump()) + except Exception as exc: + QMessageBox.critical(self, "Validación falló", str(exc)) + return + self._project.touch() + self.projectMutated.emit(self._project) + self._refresh_table() + + +class _AddAlarmDialog(QDialog): + def __init__(self, project: Project, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._project = project + self.setWindowTitle("Agregar configuración de alarma") + self.setMinimumWidth(420) + layout = QVBoxLayout(self) + form = QFormLayout() + + self._tag_combo = QComboBox() + for idx, t in enumerate(project.tags): + self._tag_combo.addItem(t.id, idx) + form.addRow("Tag:", self._tag_combo) + + self._level = QLineEdit("low") + form.addRow("Nivel (low/high/hihi/etc):", self._level) + + self._op = QComboBox() + for op in (">", ">=", "<", "<=", "==", "!="): + self._op.addItem(op, op) + form.addRow("Operador:", self._op) + + self._threshold = QDoubleSpinBox() + self._threshold.setRange(-1e9, 1e9) + self._threshold.setDecimals(3) + self._threshold.setValue(0.0) + form.addRow("Threshold:", self._threshold) + + self._priority = QComboBox() + for p in AlarmPriority: + self._priority.addItem(p.value, p.value) + form.addRow("Prioridad:", self._priority) + + self._hyst = QDoubleSpinBox() + self._hyst.setRange(0.0, 1e6) + self._hyst.setDecimals(3) + form.addRow("Histéresis:", self._hyst) + + self._delay = QDoubleSpinBox() + self._delay.setRange(0.0, 300.0) + self._delay.setDecimals(1) + form.addRow("Delay (s):", self._delay) + + self._message = QLineEdit() + form.addRow("Mensaje:", self._message) + + layout.addLayout(form) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def build(self) -> tuple[int, AlarmConfig | None]: + tag_idx = self._tag_combo.currentData() + if tag_idx is None: + return -1, None + tag = self._project.tags[tag_idx] + try: + alarm = AlarmConfig( + id=make_alarm_config_id(tag.id, self._level.text() or "low"), + threshold=self._threshold.value(), + operator=self._op.currentData(), + priority=AlarmPriority(self._priority.currentData()), + hysteresis=self._hyst.value(), + delay_seconds=self._delay.value(), + message=self._message.text(), + ) + return tag_idx, alarm + except Exception: + return -1, None diff --git a/vmssailor/studio/editors/mimic_editor.py b/vmssailor/studio/editors/mimic_editor.py new file mode 100644 index 0000000..12d8dce --- /dev/null +++ b/vmssailor/studio/editors/mimic_editor.py @@ -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) diff --git a/vmssailor/studio/editors/symbols.py b/vmssailor/studio/editors/symbols.py new file mode 100644 index 0000000..0630bdd --- /dev/null +++ b/vmssailor/studio/editors/symbols.py @@ -0,0 +1,298 @@ +"""Símbolos navales para el editor de mímicos. + +Cada símbolo es un QGraphicsItemGroup parametrizable que: +- Se dibuja con el estilo Deep Ocean +- Tiene `tag_ref: str | None` (id del Tag al que está vinculado) +- Expone `kind: str` para serialización +- Soporta `setState(value)` que actualiza color/animación + +Sprint 3: motor, bomba, válvula, tanque, sensor genérico, indicador, línea. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ClassVar + +from PySide6.QtCore import Qt +from PySide6.QtGui import ( + QBrush, + QColor, + QFont, + QLinearGradient, + QPainterPath, + QPen, +) +from PySide6.QtWidgets import ( + QGraphicsEllipseItem, + QGraphicsItem, + QGraphicsItemGroup, + QGraphicsLineItem, + QGraphicsPathItem, + QGraphicsRectItem, + QGraphicsTextItem, +) + +from vmssailor.studio.theme import ( + C_ABYSS, + C_CYAN, + C_FOAM, + C_FOG, + C_IRON, + C_MIDNIGHT, + C_OK, + C_SAND, + C_STEEL, +) + +SYMBOL_KINDS = ( + "motor", + "pump", + "valve", + "tank", + "sensor", + "indicator", + "line", + "label", +) + + +@dataclass(slots=True) +class SymbolSpec: + """Spec serializable de un símbolo dentro de un mímico.""" + + kind: str + x: float + y: float + width: float = 80.0 + height: float = 60.0 + label: str = "" + tag_ref: str | None = None + rotation_deg: float = 0.0 + extra: dict = field(default_factory=dict) + + +class _BaseSymbol(QGraphicsItemGroup): + """Símbolo base. Subclases dibujan en `_build()`.""" + + KIND: ClassVar[str] = "base" + + def __init__(self, spec: SymbolSpec) -> None: + super().__init__() + self.setFlag(QGraphicsItem.ItemIsMovable, True) + self.setFlag(QGraphicsItem.ItemIsSelectable, True) + self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) + self.setPos(spec.x, spec.y) + self.setRotation(spec.rotation_deg) + self.spec = spec + self._build() + if spec.label: + self._add_label(spec.label) + + def _build(self) -> None: + raise NotImplementedError + + def _add_label(self, text: str) -> None: + item = QGraphicsTextItem(text) + font = QFont("Inter", 8) + font.setBold(False) + item.setFont(font) + item.setDefaultTextColor(QColor(C_SAND)) + rect = item.boundingRect() + item.setPos(-rect.width() / 2 + self.spec.width / 2, self.spec.height + 4) + self.addToGroup(item) + + def set_state_color(self, color: QColor) -> None: + """Cambia el color principal del símbolo (para reflejar estado del tag).""" + # Subclases lo implementan si tienen un elemento principal recoloreable + pass + + +class MotorSymbol(_BaseSymbol): + KIND = "motor" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + # Cuerpo (rectángulo con esquinas redondeadas) + body = QGraphicsRectItem(0, 0, w, h) + body.setBrush(QBrush(QColor(C_MIDNIGHT))) + body.setPen(QPen(QColor(C_CYAN), 2)) + self.addToGroup(body) + self._body = body + # Letra M + letter = QGraphicsTextItem("M") + letter.setFont(QFont("Space Grotesk", 22, QFont.Weight.Bold)) + letter.setDefaultTextColor(QColor(C_CYAN)) + rect = letter.boundingRect() + letter.setPos((w - rect.width()) / 2, (h - rect.height()) / 2) + self.addToGroup(letter) + + def set_state_color(self, color: QColor) -> None: + self._body.setPen(QPen(color, 2)) + + +class PumpSymbol(_BaseSymbol): + KIND = "pump" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + # Circulo (cuerpo de bomba) + body = QGraphicsEllipseItem(0, 0, w, h) + body.setBrush(QBrush(QColor(C_MIDNIGHT))) + body.setPen(QPen(QColor(C_CYAN), 2)) + self.addToGroup(body) + self._body = body + # Aspas en cruz + cx, cy = w / 2, h / 2 + line1 = QGraphicsLineItem(cx - w / 3, cy, cx + w / 3, cy) + line2 = QGraphicsLineItem(cx, cy - h / 3, cx, cy + h / 3) + for ln in (line1, line2): + ln.setPen(QPen(QColor(C_CYAN), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) + self.addToGroup(ln) + center = QGraphicsEllipseItem(cx - 3, cy - 3, 6, 6) + center.setBrush(QBrush(QColor(C_CYAN))) + center.setPen(QPen(Qt.PenStyle.NoPen)) + self.addToGroup(center) + + def set_state_color(self, color: QColor) -> None: + self._body.setPen(QPen(color, 2)) + + +class ValveSymbol(_BaseSymbol): + KIND = "valve" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + # Reloj de arena (dos triángulos) + path = QPainterPath() + path.moveTo(0, 0) + path.lineTo(w, h) + path.lineTo(w, 0) + path.lineTo(0, h) + path.closeSubpath() + item = QGraphicsPathItem(path) + item.setBrush(QBrush(QColor(C_OK))) + item.setPen(QPen(QColor(C_ABYSS), 2)) + self.addToGroup(item) + self._body = item + # Vástago + stem = QGraphicsLineItem(w / 2, -h / 2, w / 2, 0) + stem.setPen(QPen(QColor(C_FOG), 2)) + self.addToGroup(stem) + handle = QGraphicsEllipseItem(w / 2 - 5, -h / 2 - 5, 10, 10) + handle.setBrush(QBrush(QColor(C_OK))) + handle.setPen(QPen(QColor(C_ABYSS))) + self.addToGroup(handle) + + def set_state_color(self, color: QColor) -> None: + self._body.setBrush(QBrush(color)) + + +class TankSymbol(_BaseSymbol): + KIND = "tank" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + body = QGraphicsRectItem(0, 0, w, h) + body.setBrush(QBrush(QColor(C_ABYSS))) + body.setPen(QPen(QColor(C_IRON), 2)) + self.addToGroup(body) + # Fill (default 50%) + fill_h = h * 0.5 + grad = QLinearGradient(0, h - fill_h, 0, h) + grad.setColorAt(0.0, QColor("#00D9FF")) + grad.setColorAt(1.0, QColor("#1B7FB5")) + fill = QGraphicsRectItem(2, h - fill_h + 2, w - 4, fill_h - 2) + fill.setBrush(QBrush(grad)) + fill.setPen(QPen(Qt.PenStyle.NoPen)) + self.addToGroup(fill) + self._fill = fill + self._h = h + self._w = w + + def set_fill_pct(self, pct: float) -> None: + pct = max(0.0, min(100.0, pct)) + fill_h = self._h * (pct / 100.0) + self._fill.setRect(2, self._h - fill_h + 2, self._w - 4, max(0, fill_h - 2)) + + +class SensorSymbol(_BaseSymbol): + KIND = "sensor" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + body = QGraphicsEllipseItem(0, 0, w, h) + body.setBrush(QBrush(QColor(C_MIDNIGHT))) + body.setPen(QPen(QColor(C_OK), 2)) + self.addToGroup(body) + self._body = body + letter = QGraphicsTextItem(self.spec.extra.get("letter", "T")) + letter.setFont(QFont("JetBrains Mono", 14, QFont.Weight.Bold)) + letter.setDefaultTextColor(QColor(C_OK)) + rect = letter.boundingRect() + letter.setPos((w - rect.width()) / 2, (h - rect.height()) / 2) + self.addToGroup(letter) + + def set_state_color(self, color: QColor) -> None: + self._body.setPen(QPen(color, 2)) + + +class IndicatorSymbol(_BaseSymbol): + KIND = "indicator" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + bg = QGraphicsRectItem(0, 0, w, h) + bg.setBrush(QBrush(QColor(C_MIDNIGHT))) + bg.setPen(QPen(QColor(C_STEEL), 1)) + self.addToGroup(bg) + value = QGraphicsTextItem(self.spec.extra.get("value", "--")) + value.setFont(QFont("JetBrains Mono", 14, QFont.Weight.Bold)) + value.setDefaultTextColor(QColor(C_FOAM)) + rect = value.boundingRect() + value.setPos((w - rect.width()) / 2, (h - rect.height()) / 2) + self.addToGroup(value) + self._value = value + + def set_value_text(self, text: str) -> None: + self._value.setPlainText(text) + + +class LineSymbol(_BaseSymbol): + KIND = "line" + + def _build(self) -> None: + w, h = self.spec.width, self.spec.height + # Línea horizontal por defecto + line = QGraphicsLineItem(0, h / 2, w, h / 2) + line.setPen(QPen(QColor(C_CYAN), 2.5, Qt.PenStyle.SolidLine)) + self.addToGroup(line) + + +class LabelSymbol(_BaseSymbol): + KIND = "label" + + def _build(self) -> None: + text = self.spec.extra.get("text", self.spec.label or "Label") + item = QGraphicsTextItem(text) + font = QFont("Inter", 10, QFont.Weight.DemiBold) + item.setFont(font) + item.setDefaultTextColor(QColor(C_FOAM)) + self.addToGroup(item) + + +SYMBOL_FACTORY = { + "motor": MotorSymbol, + "pump": PumpSymbol, + "valve": ValveSymbol, + "tank": TankSymbol, + "sensor": SensorSymbol, + "indicator": IndicatorSymbol, + "line": LineSymbol, + "label": LabelSymbol, +} + + +def make_symbol(spec: SymbolSpec) -> _BaseSymbol: + cls = SYMBOL_FACTORY.get(spec.kind, _BaseSymbol) + return cls(spec) diff --git a/vmssailor/studio/editors/tag_editor.py b/vmssailor/studio/editors/tag_editor.py new file mode 100644 index 0000000..ddae8c6 --- /dev/null +++ b/vmssailor/studio/editors/tag_editor.py @@ -0,0 +1,224 @@ +"""Editor de tags del proyecto activo (Sprint 3). + +Tabla CRUD con todos los Tag del Project. Permite editar inline: +- description +- unit_si +- range_normal_min / range_normal_max +- controllable + control_mode +- address (Modbus) o physical_binding (lectura solamente; binding se edita en topology) +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from vmssailor.core.enums import ControlMode, UnitSI +from vmssailor.core.project import Project +from vmssailor.studio.theme import C_CYAN, C_EMERGENCY, C_FOG, C_OK, mono_font, ui_font + +_COLS = [ + "ID", + "Equipo", + "Descripción", + "Unidad SI", + "Rango min", + "Rango max", + "Controlable", + "Control mode", + "Protocolo", + "Address", + "Card", + "Channel", + "Alarms", +] + + +class TagEditor(QWidget): + projectMutated = Signal(Project) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._project: Project | None = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(10) + + header = QLabel("Editor de tags") + header.setObjectName("title") + header.setFont(ui_font(14)) + layout.addWidget(header) + + intro = QLabel( + "Tabla con todos los tags del proyecto. Edita descripción, rango y " + "control_mode inline. El binding físico se ajusta en el editor de " + "topología (Sprint 4+)." + ) + intro.setStyleSheet(f"color: {C_FOG};") + intro.setFont(ui_font(10)) + intro.setWordWrap(True) + layout.addWidget(intro) + + # Filter row + filter_row = QHBoxLayout() + filter_row.addWidget(QLabel("Filtro:")) + self._filter = QLineEdit() + self._filter.setPlaceholderText("Buscar tag, equipo, descripción…") + self._filter.textChanged.connect(self._apply_filter) + filter_row.addWidget(self._filter) + + self._counter = QLabel("0 tags") + self._counter.setFont(mono_font(10)) + self._counter.setStyleSheet(f"color: {C_CYAN};") + filter_row.addWidget(self._counter) + layout.addLayout(filter_row) + + # Table + self._table = QTableWidget(0, len(_COLS)) + self._table.setHorizontalHeaderLabels(_COLS) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self._table.verticalHeader().setVisible(False) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + ) + self._table.itemChanged.connect(self._on_item_changed) + layout.addWidget(self._table, 1) + + # ----- Public API ------------------------------------------------- + + def set_project(self, project: Project | None) -> None: + self._project = project + self._refresh_table() + + # ----- Internals -------------------------------------------------- + + def _refresh_table(self) -> None: + self._table.blockSignals(True) + self._table.setRowCount(0) + if self._project is None: + self._counter.setText("0 tags") + self._table.blockSignals(False) + return + for t in self._project.tags: + self._append_row(t) + self._counter.setText(f"{len(self._project.tags)} tags") + self._table.blockSignals(False) + + def _append_row(self, tag) -> None: + row = self._table.rowCount() + self._table.insertRow(row) + + id_item = QTableWidgetItem(tag.id) + id_item.setFlags(id_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + id_item.setForeground(QColor(C_CYAN)) + self._table.setItem(row, 0, id_item) + + eq = QTableWidgetItem(tag.equipment_id or "") + eq.setFlags(eq.flags() & ~Qt.ItemFlag.ItemIsEditable) + self._table.setItem(row, 1, eq) + + self._table.setItem(row, 2, QTableWidgetItem(tag.description)) + self._table.setItem(row, 3, QTableWidgetItem(tag.unit_si.value)) + rmin = f"{tag.range_normal_min:.2f}" if tag.range_normal_min is not None else "" + rmax = f"{tag.range_normal_max:.2f}" if tag.range_normal_max is not None else "" + self._table.setItem(row, 4, QTableWidgetItem(rmin)) + self._table.setItem(row, 5, QTableWidgetItem(rmax)) + + ctrl = QTableWidgetItem("✓" if tag.controllable else "—") + ctrl.setForeground(QColor(C_OK) if tag.controllable else QColor(C_FOG)) + self._table.setItem(row, 6, ctrl) + + cmode = QTableWidgetItem(tag.control_mode.value) + cmode.setForeground(QColor(C_OK)) + self._table.setItem(row, 7, cmode) + + self._table.setItem(row, 8, QTableWidgetItem(tag.protocol.value)) + addr_text = str(tag.address) if tag.address is not None else "" + self._table.setItem(row, 9, QTableWidgetItem(addr_text)) + + if tag.physical_binding: + self._table.setItem(row, 10, QTableWidgetItem(tag.physical_binding.card_id)) + ch_text = ( + f"{tag.physical_binding.channel_type.value.upper()}" + f"{tag.physical_binding.channel_number}" + ) + self._table.setItem(row, 11, QTableWidgetItem(ch_text)) + else: + self._table.setItem(row, 10, QTableWidgetItem("")) + self._table.setItem(row, 11, QTableWidgetItem("")) + + n_alarms = len(tag.alarms) + alarms_item = QTableWidgetItem(str(n_alarms)) + if n_alarms > 0: + alarms_item.setForeground(QColor(C_EMERGENCY)) + self._table.setItem(row, 12, alarms_item) + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + if self._project is None: + return + row = item.row() + if row >= len(self._project.tags): + return + tag = self._project.tags[row] + col = item.column() + try: + if col == 2: + self._project.tags[row] = tag.model_copy(update={"description": item.text()}) + elif col == 3: + try: + new_unit = UnitSI(item.text()) + except ValueError as err: + raise ValueError( + f"Unidad SI inválida: '{item.text()}'. Ver enum UnitSI." + ) from err + self._project.tags[row] = tag.model_copy(update={"unit_si": new_unit}) + elif col == 4: + v = float(item.text()) if item.text() else None + self._project.tags[row] = tag.model_copy(update={"range_normal_min": v}) + elif col == 5: + v = float(item.text()) if item.text() else None + self._project.tags[row] = tag.model_copy(update={"range_normal_max": v}) + elif col == 7: + try: + new_mode = ControlMode(item.text()) + except ValueError as err: + raise ValueError(f"control_mode inválido: '{item.text()}'.") from err + # Si pasa a MONITOR, controllable debe ser False + controllable = new_mode != ControlMode.MONITOR + self._project.tags[row] = tag.model_copy( + update={"control_mode": new_mode, "controllable": controllable} + ) + except Exception as exc: + QMessageBox.warning(self, "Edición inválida", str(exc)) + self._refresh_table() + return + self._project.touch() + self.projectMutated.emit(self._project) + + def _apply_filter(self, text: str) -> None: + text_lower = text.lower() + for row in range(self._table.rowCount()): + visible = True + if text_lower: + row_text = " ".join( + self._table.item(row, c).text().lower() + for c in range(self._table.columnCount()) + if self._table.item(row, c) + ) + visible = text_lower in row_text + self._table.setRowHidden(row, not visible) diff --git a/vmssailor/studio/main_window.py b/vmssailor/studio/main_window.py index 38f8a5a..632ebd4 100644 --- a/vmssailor/studio/main_window.py +++ b/vmssailor/studio/main_window.py @@ -21,6 +21,7 @@ from PySide6.QtWidgets import ( QPushButton, QSplitter, QStatusBar, + QTabWidget, QToolBar, QVBoxLayout, QWidget, @@ -28,7 +29,10 @@ from PySide6.QtWidgets import ( from vmssailor.core.persistence import load_project, save_project from vmssailor.core.project import Project +from vmssailor.studio.editors.alarm_editor import AlarmEditor from vmssailor.studio.editors.equipment_editor import EquipmentEditor +from vmssailor.studio.editors.mimic_editor import MimicEditor +from vmssailor.studio.editors.tag_editor import TagEditor from vmssailor.studio.theme import ( C_CYAN, C_FOAM, @@ -125,15 +129,24 @@ class MainWindow(QMainWindow): self._sidebar.setMinimumWidth(260) self._sidebar.setMaximumWidth(380) - # Right pane: vertical splitter with canvas on top + equipment editor below + # Right pane: vertical splitter with canvas on top + editor tabs below self._canvas = VesselCanvas() self._equipment_editor = EquipmentEditor() + self._mimic_editor = MimicEditor() + self._tag_editor = TagEditor() + self._alarm_editor = AlarmEditor() + + self._tabs = QTabWidget() + self._tabs.addTab(self._equipment_editor, "Equipos") + self._tabs.addTab(self._mimic_editor, "Mímicos") + self._tabs.addTab(self._tag_editor, "Tags") + self._tabs.addTab(self._alarm_editor, "Alarmas") right_splitter = QSplitter(Qt.Vertical) right_splitter.setChildrenCollapsible(False) right_splitter.addWidget(self._canvas) - right_splitter.addWidget(self._equipment_editor) - right_splitter.setSizes([520, 380]) + right_splitter.addWidget(self._tabs) + right_splitter.setSizes([460, 440]) self._right_splitter = right_splitter self._splitter.addWidget(self._sidebar) @@ -235,7 +248,12 @@ class MainWindow(QMainWindow): self.projectChanged.connect(self._sidebar.set_project) self.projectChanged.connect(self._canvas.set_project) self.projectChanged.connect(self._equipment_editor.set_project) + self.projectChanged.connect(self._mimic_editor.set_project) + self.projectChanged.connect(self._tag_editor.set_project) + self.projectChanged.connect(self._alarm_editor.set_project) self._equipment_editor.projectMutated.connect(self._on_project_mutated) + self._tag_editor.projectMutated.connect(self._on_project_mutated) + self._alarm_editor.projectMutated.connect(self._on_project_mutated) # ----- Slots --------------------------------------------------------