Files
AR-VMS-Seaman/vmssailor/studio/main_window.py
T
alro65 813476c8db sprint-1: Studio shell PySide6 + wizard 8 pasos
Sprint 1 entrega el shell del Studio operativo. Para correrlo:
    uv run vms-studio

Componentes:

vmssailor/studio/theme.py
- Aplica design tokens del Sprint 0 (paleta Deep Ocean) a PySide6
- QSS global completo + QPalette + fuentes Inter/Space Grotesk/JetBrains Mono

vmssailor/studio/app.py
- StudioApp (QApplication) con tema, logo, version
- run_studio() entry point

vmssailor/studio/main_window.py
- Layout: topbar / sidebar / canvas central / statusbar
- Menus: Proyecto (Nuevo wizard, Abrir, Guardar, Guardar como, Salir),
  Edicion/Vista (stubs Sprint 2), Ayuda
- Operaciones funcionales: New from wizard, Open .vmsproj, Save, Save As,
  Validate (cross-entity), Compile (placeholder)
- Reloj live + statusbar con stats del proyecto

vmssailor/studio/widgets/system_sidebar.py
- Sidebar dinamico que muestra wizard steps + sistemas habilitados + disponibles
- Lee catalogo maestro y proyecto activo
- Senial systemActivated(SystemId) al doble-click

vmssailor/studio/widgets/vessel_canvas.py
- QGraphicsView central con grilla naval (1m por celda)
- Renderiza silueta del buque en planta + mamparos + equipos
- ship_to_scene() transformacion canonica naval -> escena
- Centerline + Pp axis marcados
- Ruler de eslora con marcas cada 5m
- Zoom con wheel + scroll-pan, label de zoom% en header

vmssailor/studio/wizard/ - QWizard 8 pasos
- step_01_vessel_type: tipo + subtipo + nombre proyecto + cliente
- step_02_template: selector con biblioteca curada (Sunseeker, Ferretti, blank)
- step_03_dimensions: LOA/manga/calado/mamparos con pre-fill de plantilla
- step_04_systems: checkboxes agrupados por categoria con pre-select por default_for
- step_57_placeholder: stubs visuales para Sprint 2 (pasos 5, 6, 7)
- step_08_confirm: resumen HTML completo del proyecto a crear
- VesselWizard.build_project() construye un Project valido

Tests (tests/studio/, 11 nuevos, total 110/110):
- pytest-qt offscreen
- Smoke tests del MainWindow, wizard, canvas, sidebar
- test_ship_to_scene_mapping (transformacion naval->escena)

Stack agregado:
- PySide6 6.11.1
- pytest-qt 4.5.0

Decisiones autonomas:
- QFont.setWeight requiere QFont.Weight enum en PySide6 6.11 (no int)
- QFrame.Shape.NoFrame (no QListWidget.NoFrame) para PySide6 6.11
- Pasos 5-7 quedan placeholders explicitos: Sprint 2 implementa rule engine
- Wizard crea Project sin equipment todavia (Sprint 2 los agrega)

Criterios de aceptacion Sprint 1:
- uv run vms-studio: abre ventana operativa
- 110/110 pytest verde
- ruff clean
- Smoke offscreen: MainWindow + Wizard + Canvas + Sidebar OK

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

369 lines
12 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.theme import (
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)
self._canvas = VesselCanvas()
self._splitter.addWidget(self._sidebar)
self._splitter.addWidget(self._canvas)
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)
# ----- 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