"""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"""
Versión {v} · Sprint 1
Herramienta de ingeniería para configurar el VMS-Sailor de cada buque.
Propiedad intelectual de Álvaro. Todos los derechos reservados.
""", ) # ----- 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 / {self._project.id} / 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