Files
AR-VMS-Seaman/vmssailor/studio/main_window.py
T
alro65 fbce1ecb42 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>
2026-05-17 18:34:30 -04:00

410 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ventana principal del Studio.
Layout: topbar / sidebar / canvas / inspector / statusbar (ver
`docs/mockups/studio_main.html` para la referencia visual).
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QAction, QIcon, QKeySequence
from PySide6.QtWidgets import (
QFileDialog,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QSplitter,
QStatusBar,
QTabWidget,
QToolBar,
QVBoxLayout,
QWidget,
)
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,
C_FOG,
mono_font,
ui_font,
)
from vmssailor.studio.widgets.system_sidebar import SystemSidebar
from vmssailor.studio.widgets.vessel_canvas import VesselCanvas
from vmssailor.studio.wizard.wizard import VesselWizard
logger = logging.getLogger(__name__)
BRAND_ROOT = Path(__file__).resolve().parents[2] / "docs" / "brand"
class MainWindow(QMainWindow):
"""Ventana principal del Studio."""
projectChanged = Signal(Project)
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("VMS-Sailor Studio")
self.setMinimumSize(1200, 720)
self._project: Project | None = None
self._project_path: Path | None = None
self._build_topbar()
self._build_central()
self._build_statusbar()
self._build_menus()
self._build_toolbar()
self._connect_signals()
self._update_window_title()
# Clock tick
self._clock_timer = QTimer(self)
self._clock_timer.timeout.connect(self._update_clock)
self._clock_timer.start(1000)
self._update_clock()
# ----- UI build -----------------------------------------------------
def _build_topbar(self) -> None:
bar = QWidget()
bar.setObjectName("topbar")
bar.setFixedHeight(56)
layout = QHBoxLayout(bar)
layout.setContentsMargins(20, 0, 20, 0)
layout.setSpacing(16)
logo_path = BRAND_ROOT / "logo.svg"
if logo_path.exists():
logo_label = QLabel()
pix_label = QIcon(str(logo_path)).pixmap(120, 32)
logo_label.setPixmap(pix_label)
layout.addWidget(logo_label)
# Breadcrumb area
self._breadcrumb_label = QLabel("Sin proyecto abierto")
self._breadcrumb_label.setObjectName("title")
self._breadcrumb_label.setFont(ui_font(11))
layout.addWidget(self._breadcrumb_label)
layout.addStretch(1)
self._dirty_badge = QLabel("● Sin cambios sin guardar")
self._dirty_badge.setObjectName("caption")
self._dirty_badge.setStyleSheet(f"color: {C_FOG};")
layout.addWidget(self._dirty_badge)
# Topbar action buttons
self._btn_validate = QPushButton("Validar")
self._btn_compile = QPushButton("Compilar .vmspack")
self._btn_save_top = QPushButton("Guardar")
self._btn_save_top.setObjectName("primary")
for b in (self._btn_validate, self._btn_compile, self._btn_save_top):
layout.addWidget(b)
self._topbar = bar
def _build_central(self) -> None:
# Splitter horizontal: sidebar | canvas
self._splitter = QSplitter(Qt.Horizontal)
self._splitter.setHandleWidth(1)
self._splitter.setChildrenCollapsible(False)
self._sidebar = SystemSidebar()
self._sidebar.setObjectName("sidebar")
self._sidebar.setMinimumWidth(260)
self._sidebar.setMaximumWidth(380)
# 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._tabs)
right_splitter.setSizes([460, 440])
self._right_splitter = right_splitter
self._splitter.addWidget(self._sidebar)
self._splitter.addWidget(right_splitter)
self._splitter.setSizes([280, 1160])
# Compose central widget: topbar (top) + splitter (rest)
wrapper = QWidget()
outer = QVBoxLayout(wrapper)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
outer.addWidget(self._topbar)
outer.addWidget(self._splitter, 1)
self.setCentralWidget(wrapper)
def _build_statusbar(self) -> None:
sb = QStatusBar(self)
sb.setSizeGripEnabled(False)
sb.setFont(mono_font(9))
self._stats_label = QLabel("0 sistemas · 0 equipos · 0 tags · 0 tarjetas")
sb.addWidget(self._stats_label, 1)
self._clock_label = QLabel("--:--:--")
self._clock_label.setFont(mono_font(9))
sb.addPermanentWidget(self._clock_label)
from vmssailor.version import __version__ as v
version_label = QLabel(f"Studio {v} · Sprint 1")
version_label.setFont(mono_font(9))
version_label.setStyleSheet(f"color: {C_FOG};")
sb.addPermanentWidget(version_label)
self.setStatusBar(sb)
def _build_menus(self) -> None:
mb = self.menuBar()
# ------- Archivo --------
m_file = mb.addMenu("&Proyecto")
self._act_new = QAction("&Nuevo desde wizard…", self)
self._act_new.setShortcut(QKeySequence.New)
m_file.addAction(self._act_new)
self._act_open = QAction("&Abrir .vmsproj…", self)
self._act_open.setShortcut(QKeySequence.Open)
m_file.addAction(self._act_open)
self._act_save = QAction("&Guardar", self)
self._act_save.setShortcut(QKeySequence.Save)
self._act_save.setEnabled(False)
m_file.addAction(self._act_save)
self._act_save_as = QAction("Guardar &como…", self)
self._act_save_as.setShortcut("Ctrl+Shift+S")
self._act_save_as.setEnabled(False)
m_file.addAction(self._act_save_as)
m_file.addSeparator()
self._act_exit = QAction("&Salir", self)
self._act_exit.setShortcut("Ctrl+Q")
m_file.addAction(self._act_exit)
# ------- Edición --------
m_edit = mb.addMenu("&Edición")
m_edit.addAction(QAction("(stubs Sprint 2)", self, enabled=False))
# ------- Vista --------
m_view = mb.addMenu("&Vista")
m_view.addAction(QAction("(stubs Sprint 2)", self, enabled=False))
# ------- Ayuda --------
m_help = mb.addMenu("A&yuda")
self._act_about = QAction("&Acerca de…", self)
m_help.addAction(self._act_about)
def _build_toolbar(self) -> None:
tb = QToolBar("Principal", self)
tb.setMovable(False)
tb.setIconSize(self.iconSize())
self.addToolBar(Qt.LeftToolBarArea, tb)
tb.hide() # placeholder Sprint 2
def _connect_signals(self) -> None:
self._act_new.triggered.connect(self.on_new_wizard)
self._act_open.triggered.connect(self.on_open)
self._act_save.triggered.connect(self.on_save)
self._act_save_as.triggered.connect(self.on_save_as)
self._act_exit.triggered.connect(self.close)
self._act_about.triggered.connect(self.on_about)
self._btn_save_top.clicked.connect(self.on_save)
self._btn_validate.clicked.connect(self.on_validate)
self._btn_compile.clicked.connect(self.on_compile)
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 --------------------------------------------------------
def on_new_wizard(self) -> None:
wiz = VesselWizard(self)
if wiz.exec():
project = wiz.build_project()
self.set_project(project, path=None)
self.statusBar().showMessage("Proyecto creado desde wizard.", 4000)
def on_open(self) -> None:
path_str, _ = QFileDialog.getOpenFileName(
self,
"Abrir proyecto VMS-Sailor",
"",
"VMS-Sailor projects (*.vmsproj);;Todos (*)",
)
if not path_str:
return
path = Path(path_str)
try:
project = load_project(path)
except Exception as exc:
QMessageBox.critical(self, "Error al abrir", f"No se pudo abrir {path}\n\n{exc}")
return
self.set_project(project, path=path)
self.statusBar().showMessage(f"Abierto: {path.name}", 4000)
def on_save(self) -> None:
if not self._project:
return
if not self._project_path:
self.on_save_as()
return
try:
save_project(self._project, self._project_path)
except Exception as exc:
QMessageBox.critical(self, "Error al guardar", f"{exc}")
return
self.statusBar().showMessage(f"Guardado: {self._project_path.name}", 4000)
self._dirty_badge.setText("● Sin cambios sin guardar")
def on_save_as(self) -> None:
if not self._project:
return
default = f"{self._project.id}.vmsproj"
path_str, _ = QFileDialog.getSaveFileName(
self,
"Guardar proyecto VMS-Sailor",
default,
"VMS-Sailor projects (*.vmsproj)",
)
if not path_str:
return
path = Path(path_str)
if path.suffix.lower() != ".vmsproj":
path = path.with_suffix(".vmsproj")
self._project_path = path
self.on_save()
self._update_window_title()
def on_validate(self) -> None:
if not self._project:
QMessageBox.information(self, "Validar", "Abre o crea un proyecto primero.")
return
from vmssailor.core.validation import validate_project
report = validate_project(self._project)
msg = report.format()
ok = report.ok()
title = "Validación · OK" if ok else "Validación · ERRORES"
if ok and not report.warnings and not report.infos:
QMessageBox.information(self, title, "Sin issues. Todo en regla.")
else:
QMessageBox.information(self, title, msg)
def on_compile(self) -> None:
QMessageBox.information(
self,
"Compilar .vmspack",
"El compilador .vmspack llega en Sprint 7. En Sprint 1 sólo guardamos el .vmsproj.",
)
def on_about(self) -> None:
from vmssailor.version import __version__ as v
QMessageBox.about(
self,
"VMS-Sailor Studio",
f"""<h2 style="color:{C_FOAM}">VMS-Sailor Studio</h2>
<p>Versión <strong>{v}</strong> · Sprint 1</p>
<p>Herramienta de ingeniería para configurar el VMS-Sailor de cada buque.</p>
<p><em>Propiedad intelectual de Álvaro. Todos los derechos reservados.</em></p>""",
)
# ----- Public ops ---------------------------------------------------
def set_project(self, project: Project | None, *, path: Path | None) -> None:
self._project = project
self._project_path = path
has_project = project is not None
self._act_save.setEnabled(has_project)
self._act_save_as.setEnabled(has_project)
self._btn_save_top.setEnabled(has_project)
self._btn_validate.setEnabled(has_project)
self._btn_compile.setEnabled(has_project)
self._update_window_title()
self._update_stats()
if project is not None:
self.projectChanged.emit(project)
# ----- Helpers ------------------------------------------------------
def _update_window_title(self) -> None:
if not self._project:
self.setWindowTitle("VMS-Sailor Studio — Sin proyecto")
self._breadcrumb_label.setText(
"VMS-Sailor Studio · sin proyecto abierto · usa Proyecto Nuevo desde wizard"
)
return
crumbs = f"Proyectos / <b>{self._project.id}</b> / Topología"
self._breadcrumb_label.setText(crumbs)
path_str = str(self._project_path) if self._project_path else "(sin guardar)"
self.setWindowTitle(f"VMS-Sailor Studio — {self._project.name} ({path_str})")
def _update_stats(self) -> None:
if not self._project:
self._stats_label.setText("0 sistemas · 0 equipos · 0 tags · 0 tarjetas")
return
s = self._project.stats()
self._stats_label.setText(
f"{s['systems']} sistemas · {s['equipment']} equipos · {s['tags']} tags · "
f"{s['cards']} tarjetas · {s['permissive_rules']} rules"
)
def _update_clock(self) -> None:
now = datetime.now()
self._clock_label.setText(now.strftime("%H:%M:%S · %Y-%m-%d"))
# ----- Test helpers -------------------------------------------------
def current_project(self) -> Project | None:
return self._project
def _on_project_mutated(self, project: Project) -> None:
"""El editor reemplazó el Project. Re-emit a sidebar + canvas."""
self._project = project
self._update_stats()
# Re-emit a sidebar + canvas (que ignoran al editor para evitar loop)
self._sidebar.set_project(project)
self._canvas.set_project(project)
self._dirty_badge.setText("● Cambios pendientes — guarda con Ctrl+S")
self._dirty_badge.setStyleSheet(f"color: {C_CYAN};")