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,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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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'<span style="color:{color};">{p.value}:{counts[p]}</span>')
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user