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
+4 -1
View File
@@ -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"]
+346
View File
@@ -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
+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)
+298
View File
@@ -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)
+224
View File
@@ -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 -3
View File
@@ -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 --------------------------------------------------------