Files
AR-VMS-Seaman/vmssailor/studio/main_window.py
T
alro65 6ad76a89fa sprint-2: rule engine + auto-assigner + equipment editor + biblioteca
Wizard pasos 5-7 ahora funcionales (1-4 ya estaban en Sprint 1).

vmssailor/studio/designer/rule_engine.py
- RuleContext, RuleEngine, EquipmentProposal
- Lee library/rules/*.yaml y aplica reglas heuristicas
- Filtra por vessel_type, vessel_subtype, length_overall_m range
- Selecciona candidato segun condiciones 'when' (loa min/max)
- Genera tag_prefix con sustitucion {side}/{idx}

vmssailor/studio/designer/port_auto_assigner.py
- auto_assign() greedy: 1 bus Modbus RTU + tarjetas dedicadas para motores/gensets
- Tarjeta auxiliar compartida para resto de equipos
- Mapea SignalType -> ChannelType (AI/DI/DO/RPM)
- Genera TagBindings con scaling apropiado por tipo de senal
- Respeta capacidades 10/5/4/1 de AR-NMEA-IO-v1.0
- AssignmentReport con cards + tags + warnings

vmssailor/studio/wizard/step_05_equipment.py
- Tabla con propuestas del rule engine
- Checkboxes accept/reject + edicion inline de columnas
- Boton 'Regenerar' para re-aplicar reglas

vmssailor/studio/wizard/step_06_refinement.py
- Vista resumen de equipos aceptados

vmssailor/studio/wizard/step_07_topology.py
- Llama auto_assign sobre los equipos materializados
- Muestra tabla de tarjetas con uso por canal (DO/DI/AI/RPM)
- Lista warnings de capacidad

vmssailor/studio/editors/equipment_editor.py
- CRUD de Equipment del proyecto activo
- Tabla editable inline (tag_prefix, name, model_ref, system_id, coords, deck)
- Dialog modal para agregar equipos
- Senal projectMutated para refrescar canvas + sidebar

vmssailor/studio/main_window.py
- Layout actualizado: splitter vertical en panel derecho
  (canvas arriba + equipment editor abajo)
- _on_project_mutated() re-distribuye al sidebar y canvas

Biblioteca expandida (Sprint 2 brief: 5-7 yates, 10+ motores, gensets, bombas):
- vessels: + azimut_grande_32m, princess_y85, trawler_32m_offshore, patrol_coastal_30m (total: 6)
- engines: + cat_c32_acert, mtu_16v_2000_m96, yanmar_8lv_370 (total: 5)
- gensets: + kohler_28efkozd, onan_qd13500 (total: 3)
- pumps: + jabsco_36800, grundfos_cm10 (NUEVO categoria pumps)

Tests (tests/studio/test_designer.py, 10 nuevos, total 120/120):
- Rule engine: load default, propose engines, candidate picking por LOA
- auto_assign builds topology compatible with Project (Pydantic validation)
- Equipment editor smoke

VesselWizard.build_project() ahora materializa equipment + topology + tags
desde las propuestas y la asignacion automatica del paso 7.

Criterios Sprint 2:
- uv run vms-studio crea proyecto completo desde wizard con equipos + tags + topologia
- vms-validate-library: OK 6 vessels, 10 equipment, 1 rules
- 120/120 pytest verde, ruff clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:50:33 -04:00

392 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,
QToolBar,
QVBoxLayout,
QWidget,
)
from vmssailor.core.persistence import load_project, save_project
from vmssailor.core.project import Project
from vmssailor.studio.editors.equipment_editor import EquipmentEditor
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 + equipment editor below
self._canvas = VesselCanvas()
self._equipment_editor = EquipmentEditor()
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])
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._equipment_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};")