"""Ventana principal del cliente Runtime. Layout: topbar (vessel name + status + alarms badge) / sidebar (Overview/Mímicos/Alarmas/Trends/Trim) / central stacked widget / ticker. Sprint 6: Overview + Alarmas funcionales. Mímicos/Trends/Trim son stubs. """ from __future__ import annotations import logging from datetime import datetime from pathlib import Path from PySide6.QtCore import Qt, QTimer, Slot from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QMainWindow, QStackedWidget, QStatusBar, QVBoxLayout, QWidget, ) from vmssailor.runtime.client.views.alarms_view import AlarmsView from vmssailor.runtime.client.views.connection_view import ConnectionView from vmssailor.runtime.client.views.overview_view import OverviewView from vmssailor.studio.theme import ( C_FOAM, C_FOG, C_OK, C_SAND, C_STEEL, C_WARN, mono_font, ui_font, ) logger = logging.getLogger(__name__) BRAND_ROOT = Path(__file__).resolve().parents[3] / "docs" / "brand" class RuntimeClientWindow(QMainWindow): """Ventana principal del cliente Runtime (puente / máquinas).""" def __init__(self) -> None: super().__init__() self.setWindowTitle("VMS-Sailor Runtime") self.setMinimumSize(1100, 700) self._user = "operator" self._build_topbar() self._build_central() self._build_statusbar() self._connect_signals() # Clock self._clock_timer = QTimer(self) self._clock_timer.timeout.connect(self._update_clock) self._clock_timer.start(1000) self._update_clock() # ----- Build ------------------------------------------------------- def _build_topbar(self) -> None: bar = QWidget() bar.setObjectName("topbar") bar.setFixedHeight(64) layout = QHBoxLayout(bar) layout.setContentsMargins(24, 0, 24, 0) layout.setSpacing(16) logo_path = BRAND_ROOT / "logo-mark.svg" if logo_path.exists(): logo = QLabel() logo.setPixmap(QIcon(str(logo_path)).pixmap(36, 36)) layout.addWidget(logo) vessel_box = QVBoxLayout() vessel_box.setSpacing(0) self._vessel_name_label = QLabel("VMS-Sailor Runtime") self._vessel_name_label.setFont(ui_font(14)) self._vessel_name_label.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;") self._vessel_sub_label = QLabel("Sin conexión") self._vessel_sub_label.setFont(ui_font(9)) self._vessel_sub_label.setStyleSheet(f"color: {C_FOG}; letter-spacing: 1.5px;") vessel_box.addWidget(self._vessel_name_label) vessel_box.addWidget(self._vessel_sub_label) layout.addLayout(vessel_box) layout.addStretch(1) self._status_pill = QLabel("● Desconectado") self._status_pill.setFont(ui_font(11)) self._status_pill.setStyleSheet( f"color: {C_FOG}; padding: 6px 14px; border: 1px solid {C_FOG}; border-radius: 999px;" ) layout.addWidget(self._status_pill) self._alarm_chip = QLabel("0 alarmas") self._alarm_chip.setFont(ui_font(11)) self._alarm_chip.setStyleSheet( f"color: {C_OK}; padding: 6px 14px; border: 1px solid {C_OK}; border-radius: 999px;" ) layout.addWidget(self._alarm_chip) self._user_chip = QLabel(f"Usuario: {self._user}") self._user_chip.setFont(mono_font(10)) self._user_chip.setStyleSheet( f"color: {C_SAND}; background: {C_STEEL}; padding: 6px 14px; border-radius: 999px;" ) layout.addWidget(self._user_chip) self._topbar = bar def _build_central(self) -> None: wrapper = QWidget() outer = QVBoxLayout(wrapper) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) outer.addWidget(self._topbar) body = QHBoxLayout() body.setContentsMargins(0, 0, 0, 0) body.setSpacing(0) # Sidebar sidebar = QWidget() sidebar.setObjectName("sidebar") sidebar.setFixedWidth(220) sb_layout = QVBoxLayout(sidebar) sb_layout.setContentsMargins(8, 12, 8, 12) sb_layout.setSpacing(4) self._nav = QListWidget() self._nav.setStyleSheet("border: none;") self._nav_items = ["Overview", "Mímicos", "Alarmas", "Trends", "Trim", "Log Book", "Conexión"] for name in self._nav_items: QListWidgetItem(name, self._nav) self._nav.setCurrentRow(0) sb_layout.addWidget(self._nav, 1) body.addWidget(sidebar) # Central stack self._stack = QStackedWidget() self._overview = OverviewView() self._alarms = AlarmsView() self._connection = ConnectionView() self._stack.addWidget(self._overview) self._stack.addWidget(self._placeholder("Mímicos · Sprint 6+")) self._stack.addWidget(self._alarms) self._stack.addWidget(self._placeholder("Trends · Sprint 7+")) self._stack.addWidget(self._placeholder("Trim & Maniobra · Sprint 8")) self._stack.addWidget(self._placeholder("Log Book · Sprint 6+")) self._stack.addWidget(self._connection) body.addWidget(self._stack, 1) outer.addLayout(body, 1) self.setCentralWidget(wrapper) def _build_statusbar(self) -> None: sb = QStatusBar(self) sb.setSizeGripEnabled(False) sb.setFont(mono_font(9)) self._ws_label = QLabel("WS: disconnected") self._ws_label.setFont(mono_font(9)) self._ws_label.setStyleSheet(f"color: {C_FOG};") sb.addWidget(self._ws_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 ver_label = QLabel(f"Runtime client {v}") ver_label.setFont(mono_font(9)) ver_label.setStyleSheet(f"color: {C_FOG};") sb.addPermanentWidget(ver_label) self.setStatusBar(sb) def _connect_signals(self) -> None: self._nav.currentRowChanged.connect(self._stack.setCurrentIndex) self._connection.connectionStateChanged.connect(self._on_connection_state_change) self._connection.eventReceived.connect(self._on_event) self._connection.projectLoaded.connect(self._on_project_loaded) self._alarms.acknowledged.connect(self._on_ack_request) # ----- Helpers ----------------------------------------------------- def _placeholder(self, title: str) -> QWidget: w = QWidget() lo = QVBoxLayout(w) lo.addStretch(1) big = QLabel(title) big.setFont(ui_font(20)) big.setStyleSheet(f"color: {C_FOG};") big.setAlignment(big.alignment() | Qt.AlignmentFlag.AlignCenter) lo.addWidget(big) lo.addStretch(2) return w def _update_clock(self) -> None: self._clock_label.setText(datetime.now().strftime("%H:%M:%S · %Y-%m-%d")) # ----- Slots ------------------------------------------------------- @Slot(str) def _on_connection_state_change(self, state: str) -> None: if state == "connected": self._status_pill.setText("● Conectado") self._status_pill.setStyleSheet( f"color: {C_OK}; padding: 6px 14px; border: 1px solid {C_OK}; border-radius: 999px;" ) self._ws_label.setText("WS: connected") self._ws_label.setStyleSheet(f"color: {C_OK};") elif state == "connecting": self._status_pill.setText("● Conectando…") self._status_pill.setStyleSheet( f"color: {C_WARN}; padding: 6px 14px; border: 1px solid {C_WARN}; border-radius: 999px;" ) self._ws_label.setText("WS: connecting") self._ws_label.setStyleSheet(f"color: {C_WARN};") else: self._status_pill.setText("● Desconectado") self._status_pill.setStyleSheet( f"color: {C_FOG}; padding: 6px 14px; border: 1px solid {C_FOG}; border-radius: 999px;" ) self._ws_label.setText("WS: disconnected") self._ws_label.setStyleSheet(f"color: {C_FOG};") @Slot(dict) def _on_event(self, event: dict) -> None: self._overview.handle_event(event) self._alarms.handle_event(event) @Slot(dict) def _on_project_loaded(self, project: dict) -> None: self._vessel_name_label.setText(project.get("name", "—")) v = project.get("vessel", {}) sub = f"{v.get('name', '')} · {v.get('loa_m', 0):.1f} m" self._vessel_sub_label.setText(sub) self._overview.set_project(project) @Slot(str) def _on_ack_request(self, alarm_id: str) -> None: self._connection.request_ack(alarm_id, user=self._user)