Files
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

393 lines
8.8 KiB
Python

"""Tema visual del Studio — aplica los design tokens del Sprint 0 a PySide6.
Los tokens canónicos viven en `docs/design_system.md` y `docs/mockups/_tokens.css`.
Este módulo replica esos valores en Python para construir el QSS (Qt Style Sheets)
y los QPalette necesarios.
"""
from __future__ import annotations
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
from PySide6.QtWidgets import QApplication
# ----- Paleta Deep Ocean (canónica) --------------------------------------
C_ABYSS = "#04111F"
C_MIDNIGHT = "#0A1A2E"
C_STEEL = "#1A2B42"
C_IRON = "#2C3E5C"
C_FOG = "#7C8B9F"
C_SAND = "#E6EAF0"
C_FOAM = "#F2F5F9"
C_CYAN = "#00D9FF"
C_CYAN_DEEP = "#1B7FB5"
C_HORIZON = "#5BC0EB"
C_OK = "#00E08A"
C_INFO = "#5BC0EB"
C_WARN = "#FFB020"
C_HIGH = "#FF8030"
C_EMERGENCY = "#FF3B47"
# ----- Fuentes -----------------------------------------------------------
FAMILY_DISPLAY = "Space Grotesk"
FAMILY_UI = "Inter"
FAMILY_MONO = "JetBrains Mono"
FALLBACK_DISPLAY = "Segoe UI"
FALLBACK_UI = "Segoe UI"
FALLBACK_MONO = "Cascadia Mono"
def best_family(preferred: str, fallback: str) -> str:
"""Devuelve `preferred` si está instalado, si no `fallback`."""
families = set(QFontDatabase.families())
if preferred in families:
return preferred
return fallback
def display_font(
point_size: int = 16, weight: QFont.Weight | int = QFont.Weight.DemiBold
) -> QFont:
f = QFont(best_family(FAMILY_DISPLAY, FALLBACK_DISPLAY))
f.setPointSize(point_size)
if isinstance(weight, int):
# PySide6 6.11 requiere QFont.Weight, no int. Aproximamos.
if weight <= 300:
weight = QFont.Weight.Light
elif weight <= 400:
weight = QFont.Weight.Normal
elif weight <= 500:
weight = QFont.Weight.Medium
elif weight <= 600:
weight = QFont.Weight.DemiBold
elif weight <= 700:
weight = QFont.Weight.Bold
else:
weight = QFont.Weight.Black
f.setWeight(weight)
return f
def ui_font(point_size: int = 10) -> QFont:
f = QFont(best_family(FAMILY_UI, FALLBACK_UI))
f.setPointSize(point_size)
return f
def mono_font(point_size: int = 10) -> QFont:
f = QFont(best_family(FAMILY_MONO, FALLBACK_MONO))
f.setPointSize(point_size)
return f
# ----- Stylesheet global -------------------------------------------------
_QSS = f"""
QMainWindow, QDialog, QWidget {{
background-color: {C_ABYSS};
color: {C_SAND};
}}
QWidget#topbar {{
background-color: {C_MIDNIGHT};
border-bottom: 1px solid {C_STEEL};
}}
QWidget#sidebar {{
background-color: {C_MIDNIGHT};
border-right: 1px solid {C_STEEL};
}}
QWidget#statusbar {{
background-color: {C_MIDNIGHT};
border-top: 1px solid {C_STEEL};
color: {C_FOG};
}}
QLabel {{
color: {C_SAND};
}}
QLabel#title {{
color: {C_FOAM};
font-weight: 600;
}}
QLabel#h1 {{
color: {C_FOAM};
font-size: 22pt;
font-weight: 600;
}}
QLabel#caption {{
color: {C_FOG};
font-size: 9pt;
}}
QLabel#overline {{
color: {C_FOG};
font-size: 9pt;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}}
QPushButton {{
background-color: transparent;
color: {C_SAND};
border: 1px solid {C_IRON};
border-radius: 6px;
padding: 8px 16px;
min-width: 80px;
}}
QPushButton:hover {{
background-color: {C_STEEL};
}}
QPushButton:pressed {{
background-color: {C_IRON};
}}
QPushButton:disabled {{
background-color: {C_STEEL};
color: {C_FOG};
border-color: transparent;
}}
QPushButton#primary {{
background-color: {C_CYAN};
color: {C_ABYSS};
border: none;
font-weight: 600;
}}
QPushButton#primary:hover {{
background-color: {C_HORIZON};
}}
QPushButton#danger {{
background-color: {C_EMERGENCY};
color: {C_FOAM};
border: none;
font-weight: 700;
}}
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit, QTextEdit {{
background-color: {C_STEEL};
color: {C_FOAM};
border: 1px solid {C_IRON};
border-radius: 4px;
padding: 6px 10px;
selection-background-color: {C_CYAN};
selection-color: {C_ABYSS};
}}
QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus {{
border-color: {C_CYAN};
}}
QComboBox::drop-down {{
border: none;
width: 24px;
}}
QComboBox::down-arrow {{
image: none;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid {C_FOG};
margin-right: 8px;
}}
QComboBox QAbstractItemView {{
background-color: {C_MIDNIGHT};
color: {C_SAND};
border: 1px solid {C_IRON};
selection-background-color: {C_CYAN_DEEP};
selection-color: {C_FOAM};
}}
QCheckBox {{
color: {C_SAND};
spacing: 8px;
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
background-color: {C_STEEL};
border: 1px solid {C_IRON};
border-radius: 3px;
}}
QCheckBox::indicator:hover {{
border-color: {C_CYAN};
}}
QCheckBox::indicator:checked {{
background-color: {C_CYAN};
border-color: {C_CYAN};
image: none;
}}
QListWidget, QTreeWidget, QTableWidget {{
background-color: {C_MIDNIGHT};
color: {C_SAND};
border: 1px solid {C_STEEL};
border-radius: 6px;
outline: 0;
}}
QListWidget::item, QTreeWidget::item {{
padding: 8px 10px;
border-radius: 4px;
}}
QListWidget::item:hover, QTreeWidget::item:hover {{
background-color: {C_STEEL};
}}
QListWidget::item:selected, QTreeWidget::item:selected {{
background-color: rgba(0, 217, 255, 30);
color: {C_FOAM};
border-left: 2px solid {C_CYAN};
}}
QHeaderView::section {{
background-color: {C_MIDNIGHT};
color: {C_FOG};
border: none;
border-right: 1px solid {C_STEEL};
padding: 8px;
font-weight: 600;
}}
QMenuBar {{
background-color: {C_MIDNIGHT};
color: {C_SAND};
border-bottom: 1px solid {C_STEEL};
}}
QMenuBar::item {{
padding: 6px 12px;
background: transparent;
}}
QMenuBar::item:selected {{
background-color: {C_STEEL};
color: {C_CYAN};
}}
QMenu {{
background-color: {C_MIDNIGHT};
color: {C_SAND};
border: 1px solid {C_IRON};
}}
QMenu::item {{
padding: 6px 24px;
}}
QMenu::item:selected {{
background-color: {C_STEEL};
color: {C_CYAN};
}}
QToolBar {{
background-color: {C_MIDNIGHT};
border: none;
spacing: 4px;
padding: 4px;
}}
QStatusBar {{
background-color: {C_MIDNIGHT};
color: {C_FOG};
border-top: 1px solid {C_STEEL};
}}
QScrollBar:vertical {{
background: {C_ABYSS};
width: 12px;
border: none;
}}
QScrollBar::handle:vertical {{
background: {C_IRON};
border-radius: 6px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background: {C_FOG};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0;
}}
QScrollBar:horizontal {{
background: {C_ABYSS};
height: 12px;
border: none;
}}
QScrollBar::handle:horizontal {{
background: {C_IRON};
border-radius: 6px;
min-width: 30px;
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
height: 0;
}}
QGroupBox {{
color: {C_FOAM};
border: 1px solid {C_STEEL};
border-radius: 8px;
margin-top: 18px;
padding-top: 12px;
font-weight: 600;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: {C_FOG};
font-size: 9pt;
letter-spacing: 1.5px;
text-transform: uppercase;
}}
QWizard {{
background-color: {C_ABYSS};
}}
QWizard QWidget {{
background-color: {C_ABYSS};
}}
QToolTip {{
background-color: {C_MIDNIGHT};
color: {C_FOAM};
border: 1px solid {C_CYAN};
padding: 6px 10px;
border-radius: 4px;
}}
"""
def apply_theme(app: QApplication) -> None:
"""Aplica el tema completo Deep Ocean a la app.
- Stylesheet QSS
- QPalette (para widgets que no respetan QSS)
- Fuentes default
"""
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.Window, QColor(C_ABYSS))
palette.setColor(QPalette.WindowText, QColor(C_SAND))
palette.setColor(QPalette.Base, QColor(C_MIDNIGHT))
palette.setColor(QPalette.AlternateBase, QColor(C_STEEL))
palette.setColor(QPalette.Text, QColor(C_FOAM))
palette.setColor(QPalette.Button, QColor(C_STEEL))
palette.setColor(QPalette.ButtonText, QColor(C_SAND))
palette.setColor(QPalette.BrightText, QColor(C_EMERGENCY))
palette.setColor(QPalette.Highlight, QColor(C_CYAN))
palette.setColor(QPalette.HighlightedText, QColor(C_ABYSS))
palette.setColor(QPalette.Link, QColor(C_CYAN))
palette.setColor(QPalette.ToolTipBase, QColor(C_MIDNIGHT))
palette.setColor(QPalette.ToolTipText, QColor(C_FOAM))
palette.setColor(QPalette.PlaceholderText, QColor(C_FOG))
app.setPalette(palette)
app.setFont(ui_font(10))
app.setStyleSheet(_QSS)