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>
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user