diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index b063476..b0ffab3 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -1,19 +1,18 @@ """ Ventana principal de AR-ShipDesign. -Layout inspirado en DELFTship: - ┌─────────────────────────────────────────────────────────┐ - │ Menú | Toolbar │ - ├──────────┬──────────────────────────────┬───────────────┤ - │ │ │ │ - │ Árbol │ Vista central (tabs) │ Propiedades │ - │ Proyecto│ 3D / Líneas / Análisis │ │ - │ │ │ │ - ├──────────┴──────────────────────────────┴───────────────┤ - │ PANEL HIDROSTÁTICOS EN VIVO (siempre visible) │ - ├─────────────────────────────────────────────────────────┤ - │ Barra de tabs de módulos │ - └─────────────────────────────────────────────────────────┘ +Layout estilo DELFTship / Maxsurf: + - Ribbon bar con pestañas funcionales + (Home / Geometría / Análisis / Tanques / Sistemas / Fabricación) + - Dock izquierdo: panel de capas (estilo DELFTship) + - Área central: 4 viewports por defecto + (Perspectiva 3D · Profile · Body Plan · Vista de Planta) + - Dock derecho: propiedades del elemento seleccionado + - Panel inferior fijo: hidrostáticos en vivo (36 px) + - Barra de estado + +Los módulos de análisis se activan desde el menú o el ribbon y +reemplazan el área central temporalmente. """ from __future__ import annotations @@ -22,8 +21,8 @@ import json from pathlib import Path from typing import Optional -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QAction, QFont, QIcon, QKeySequence +from PySide6.QtCore import Qt, QSize, Signal +from PySide6.QtGui import QAction, QFont, QKeySequence, QIcon from PySide6.QtWidgets import ( QApplication, QDockWidget, @@ -33,15 +32,16 @@ from PySide6.QtWidgets import ( QLabel, QMainWindow, QMessageBox, + QScrollArea, QSizePolicy, QSplitter, - QStatusBar, - QTabWidget, + QStackedWidget, QToolBar, - QTreeWidget, - QTreeWidgetItem, + QToolButton, QVBoxLayout, QWidget, + QStyle, + QAbstractItemView, ) from arshipdesign import __version__ @@ -58,125 +58,136 @@ from arshipdesign.utils.settings import ( logger = get_logger("ui.main_window") -# Carga de strings de i18n + +# ───────────────────────────────────────────────────────────────────────────── +# Utilidades +# ───────────────────────────────────────────────────────────────────────────── + def _load_i18n(lang: str = "es") -> dict: - i18n_path = Path(__file__).parent / "i18n" / f"{lang}.json" - if not i18n_path.exists(): - i18n_path = Path(__file__).parent / "i18n" / "es.json" + path = Path(__file__).parent / "i18n" / f"{lang}.json" + if not path.exists(): + path = Path(__file__).parent / "i18n" / "es.json" try: - return json.loads(i18n_path.read_text(encoding="utf-8")) + return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} +def _spi(sp) -> QIcon: + """Icono estándar del sistema.""" + return QApplication.style().standardIcon(sp) + + +# ───────────────────────────────────────────────────────────────────────────── +# PANEL DE HIDROSTÁTICOS +# ───────────────────────────────────────────────────────────────────────────── + class HydrostaticsPanel(QFrame): """ - Panel de hidrostáticos en vivo — siempre visible en la parte inferior. - - En Sprint 2 se conectará al motor de cálculo. - Por ahora muestra valores placeholder. + Barra de hidrostáticos en vivo — siempre visible en la parte inferior. + Se conecta al motor de cálculo en Sprint 2. """ def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.strings = strings self.setObjectName("hydrostaticsPanel") - self.setFixedHeight(62) + self.setFixedHeight(36) self._build_ui() def _build_ui(self) -> None: layout = QHBoxLayout(self) - layout.setContentsMargins(10, 4, 10, 4) + layout.setContentsMargins(10, 0, 10, 0) layout.setSpacing(0) - # Título - title = QLabel(" HIDROSTÁTICOS ") - title.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold)) - title.setProperty("label", True) + title = QLabel("HIDROSTÁTICOS") + title.setObjectName("hydroTitle") layout.addWidget(title) + layout.addWidget(self._sep()) - sep = self._make_sep() - layout.addWidget(sep) - - # Campos hidrostáticos self._fields: dict[str, QLabel] = {} - hydro_items = [ - ("T", "3.20 m", "Calado [m]"), - ("Δ", "2 845 t", "Desplazamiento [t]"), - ("LCB", "12.30 m", "Centro Long. Carena [m desde AP]"), - ("KB", "1.85 m", "Centro Vert. Carena [m]"), - ("KMT", "4.20 m", "Altura Metacéntrica Transv. [m]"), - ("GMT", "1.05 m", "Altura Metacéntrica Corregida [m]"), - ("TPC", "8.2 t/cm", "Toneladas por cm de Inmersión"), - ("MCT", "42.5 t·m/cm", "Momento para Cambiar Asiento 1 cm"), - ("Cb", "0.682", "Coeficiente de Bloque"), - ("Cw", "0.821", "Coeficiente Plano Flotación"), - ("Cm", "0.985", "Coeficiente Cuaderna Maestra"), + mono = QFont("Consolas", 11) + + items = [ + ("T", "—", "Calado [m]"), + ("Δ", "—", "Desplazamiento [t]"), + ("LCB", "—", "Centro Longitudinal de Carena [m desde AP]"), + ("KB", "—", "Centro Vertical de Carena [m]"), + ("KMT", "—", "Altura Metacéntrica Transversal [m]"), + ("GMT", "—", "Altura Metacéntrica Corregida [m]"), + ("TPC", "—", "Toneladas por cm de Inmersión"), + ("MCT", "—", "Momento para Cambiar Asiento 1 cm [t·m/cm]"), + ("Cb", "—", "Coeficiente de Bloque"), + ("Cw", "—", "Coeficiente de Plano de Flotación"), + ("Cm", "—", "Coeficiente de Cuaderna Maestra"), ] - for key, default_val, tooltip in hydro_items: - lbl_key = QLabel(f" {key} ") - lbl_key.setProperty("label", True) - lbl_key.setToolTip(tooltip) + for key, default, tip in items: + k = QLabel(f" {key} ") + k.setObjectName("hydroKey") + k.setToolTip(tip) + v = QLabel(default) + v.setObjectName("hydroVal") + v.setFont(mono) + v.setToolTip(tip) + v.setMinimumWidth(52) + self._fields[key] = v + layout.addWidget(k) + layout.addWidget(v) + layout.addWidget(self._sep()) - lbl_val = QLabel(default_val) - lbl_val.setProperty("value", True) - lbl_val.setToolTip(tooltip) - lbl_val.setMinimumWidth(72) - - self._fields[key] = lbl_val - layout.addWidget(lbl_key) - layout.addWidget(lbl_val) - - sep = self._make_sep() - layout.addWidget(sep) - - # Indicador IMO - self._imo_label = QLabel(" ⚠ IMO — ") - self._imo_label.setProperty("label", True) - self._imo_status = QLabel("SIN DATOS") - self._imo_status.setToolTip("Cumplimiento IMO IS Code 2008. Activo cuando haya un caso de carga calculado.") - layout.addWidget(self._imo_label) - layout.addWidget(self._imo_status) + imo_k = QLabel(" IMO ") + imo_k.setObjectName("hydroKey") + self._imo = QLabel("SIN DATOS") + self._imo.setObjectName("hydroImoNone") + self._imo.setFont(mono) + self._imo.setToolTip("Cumplimiento IMO IS Code 2008") + layout.addWidget(imo_k) + layout.addWidget(self._imo) layout.addStretch() @staticmethod - def _make_sep() -> QFrame: - sep = QFrame() - sep.setFrameShape(QFrame.Shape.VLine) - sep.setFrameShadow(QFrame.Shadow.Sunken) - sep.setFixedWidth(1) - sep.setStyleSheet("QFrame { color: #3a3f4b; margin: 6px 4px; }") - return sep + def _sep() -> QFrame: + f = QFrame() + f.setFrameShape(QFrame.Shape.VLine) + f.setObjectName("hydroSep") + f.setFixedWidth(1) + return f def update_values(self, values: dict[str, str]) -> None: - """Actualiza los valores del panel. Llamar desde el motor de cálculo.""" for key, val in values.items(): if key in self._fields: self._fields[key].setText(val) def set_imo_status(self, ok: bool, detail: str = "") -> None: if ok: - self._imo_status.setText("✅ CUMPLE") - self._imo_status.setProperty("imo_ok", True) - self._imo_status.setProperty("imo_fail", False) + self._imo.setText("CUMPLE") + self._imo.setObjectName("hydroImoOk") else: - self._imo_status.setText(f"❌ FALLA {detail}") - self._imo_status.setProperty("imo_ok", False) - self._imo_status.setProperty("imo_fail", True) - self._imo_status.style().polish(self._imo_status) + self._imo.setText(f"FALLA {detail}".strip()) + self._imo.setObjectName("hydroImoFail") + self._imo.style().polish(self._imo) -class ProjectTreePanel(QWidget): - """Panel árbol de proyecto (izquierda).""" +# ───────────────────────────────────────────────────────────────────────────── +# VIEWPORTS — 4 vistas estilo Maxsurf / DELFTship +# ───────────────────────────────────────────────────────────────────────────── - item_selected = Signal(str) # nombre del ítem seleccionado +_VIEW_LABELS: dict[str, str] = { + "perspective": "Perspectiva 3D", + "profile": "Vista Lateral — Perfil", + "bodyplan": "Body Plan — Secciones", + "plan": "Vista de Planta", +} - def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None: + +class ViewportFrame(QFrame): + """Un viewport individual con barra de título.""" + + def __init__(self, view_type: str, parent: Optional[QWidget] = None) -> None: super().__init__(parent) - self.strings = strings - self.setObjectName("projectTree") - self.setMinimumWidth(180) + self.view_type = view_type + self.setObjectName("viewportFrame") self._build_ui() def _build_ui(self) -> None: @@ -184,67 +195,255 @@ class ProjectTreePanel(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - title = QLabel(f" {self.strings.get('panel_project', 'Proyecto')}") - title.setFixedHeight(28) - title.setStyleSheet("background: #252830; color: #90caf9; font-weight: bold; padding-left: 8px;") - layout.addWidget(title) + # ── Barra de título ────────────────────────────────────── + title_bar = QWidget() + title_bar.setObjectName("viewportTitleBar") + title_bar.setFixedHeight(22) + tbl = QHBoxLayout(title_bar) + tbl.setContentsMargins(8, 0, 4, 0) + tbl.setSpacing(0) - self.tree = QTreeWidget() - self.tree.setHeaderHidden(True) - self.tree.setIndentation(16) - self.tree.setAnimated(True) - layout.addWidget(self.tree) + lbl = QLabel(_VIEW_LABELS.get(self.view_type, self.view_type)) + lbl.setObjectName("viewportTitle") + tbl.addWidget(lbl) + tbl.addStretch() + layout.addWidget(title_bar) - self._populate_default() - self.tree.itemClicked.connect(self._on_item_clicked) + # ── Área de dibujo (placeholder Sprint 0) ──────────────── + self._canvas = QWidget() + self._canvas.setObjectName("viewportCanvas") + cl = QVBoxLayout(self._canvas) + cl.setAlignment(Qt.AlignmentFlag.AlignCenter) - def _populate_default(self) -> None: - self.tree.clear() - root_items = [ - ("🚢 Buque", [ - ("📐 Casco", ["Superficie 1"]), - ("⚓ Apéndices", ["Quilla", "Timón"]), - ("🏗 Superestructura", []), - ]), - ("⛽ Tanques", ["FO 1 BR", "FO 1 ER", "FW 1", "Lastre AP"]), - ("📦 Bodegas", []), - ("📊 Casos de Carga", ["Lightship", "Salida Lleno", "Llegada Lleno", "Lastre"]), - ("⛵ Aparejo", ["Mástil Principal", "Mayor", "Génova"]), - ("⚙ Motor", ["Motor Principal", "Hélice"]), - ("🔌 Sistemas", [ - "Eléctrico", "Combustible", "Agua Dulce", - "Achique", "Lastre", "C. Incendios", "HVAC" - ]), - ("🏭 Fabricación CNC", []), - ("🧴 Moldes FRP", []), - ] + ph = QLabel(_VIEW_LABELS.get(self.view_type, "")) + ph.setObjectName("viewportPlaceholder") + ph.setAlignment(Qt.AlignmentFlag.AlignCenter) + cl.addWidget(ph) + layout.addWidget(self._canvas, 1) - for name, children in root_items: - parent = QTreeWidgetItem(self.tree, [name]) - parent.setExpanded(False) - self._add_children(parent, children) + def set_canvas(self, widget: QWidget) -> None: + """Sprint 1: sustituye el placeholder por el widget 3D / 2D real.""" + lo = self.layout() + if lo.count() > 1: + old = lo.takeAt(1).widget() + if old: + old.deleteLater() + lo.addWidget(widget, 1) + self._canvas = widget - self.tree.expandToDepth(0) - def _add_children(self, parent: QTreeWidgetItem, children: list) -> None: - for child in children: - if isinstance(child, tuple): - child_name, grandchildren = child - child_item = QTreeWidgetItem(parent, [child_name]) - self._add_children(child_item, grandchildren) - else: - QTreeWidgetItem(parent, [child]) +class FourViewport(QWidget): + """ + Layout de 4 viewports con separadores arrastrables. + Topología: + ┌─────────────┬──────────────┐ + │ Perspectiva │ Perf. Lateral│ + ├─────────────┼──────────────┤ + │ Body Plan │ Vista Planta│ + └─────────────┴──────────────┘ + """ - def _on_item_clicked(self, item: QTreeWidgetItem, _col: int) -> None: - self.item_selected.emit(item.text(0)) + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("fourViewport") + self._build_ui() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + v_split = QSplitter(Qt.Orientation.Vertical) + v_split.setObjectName("viewportSplitter") + v_split.setHandleWidth(3) + + top_split = QSplitter(Qt.Orientation.Horizontal) + top_split.setObjectName("viewportSplitter") + top_split.setHandleWidth(3) + self._vp_perspective = ViewportFrame("perspective") + self._vp_profile = ViewportFrame("profile") + top_split.addWidget(self._vp_perspective) + top_split.addWidget(self._vp_profile) + top_split.setSizes([600, 600]) + + bot_split = QSplitter(Qt.Orientation.Horizontal) + bot_split.setObjectName("viewportSplitter") + bot_split.setHandleWidth(3) + self._vp_bodyplan = ViewportFrame("bodyplan") + self._vp_plan = ViewportFrame("plan") + bot_split.addWidget(self._vp_bodyplan) + bot_split.addWidget(self._vp_plan) + bot_split.setSizes([600, 600]) + + v_split.addWidget(top_split) + v_split.addWidget(bot_split) + v_split.setSizes([400, 400]) + layout.addWidget(v_split) + + def viewport(self, view_type: str) -> Optional[ViewportFrame]: + return { + "perspective": self._vp_perspective, + "profile": self._vp_profile, + "bodyplan": self._vp_bodyplan, + "plan": self._vp_plan, + }.get(view_type) + + +# ───────────────────────────────────────────────────────────────────────────── +# PANEL DE CAPAS (estilo DELFTship) +# ───────────────────────────────────────────────────────────────────────────── + +_LAYER_PRESETS: list[tuple[str, str, bool, bool]] = [ + # (nombre, color, visible, bloqueado) + ("Casco", "#4a9eff", True, False), + ("Superestructura", "#6ee7b7", True, False), + ("Apéndices", "#fbbf24", True, False), + ("Cubierta", "#f87171", True, False), + ("Interiores", "#a78bfa", False, False), + ("Tanques", "#34d399", True, False), + ("Estructuras", "#fb923c", False, False), + ("Líneas de ref.", "#94a3b8", True, False), + ("Moldes FRP", "#e879f9", False, False), + ("CNC Corte", "#e2e8f0", False, False), +] + + +class LayerRow(QWidget): + """Fila de una capa: ojo de visibilidad · candado · muestra de color · nombre.""" + + visibility_toggled = Signal(str, bool) + lock_toggled = Signal(str, bool) + + def __init__( + self, + name: str, + color: str = "#4a9eff", + visible: bool = True, + locked: bool = False, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(parent) + self.layer_name = name + self.setObjectName("layerRow") + self._build_ui(name, color, visible, locked) + + def _build_ui(self, name: str, color: str, visible: bool, locked: bool) -> None: + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 1, 4, 1) + layout.setSpacing(3) + + self._vis_btn = QToolButton() + self._vis_btn.setObjectName("layerVisBtn") + self._vis_btn.setCheckable(True) + self._vis_btn.setChecked(visible) + self._vis_btn.setFixedSize(16, 16) + self._vis_btn.setToolTip("Mostrar / Ocultar capa") + self._vis_btn.clicked.connect(lambda v: self.visibility_toggled.emit(name, v)) + + self._lock_btn = QToolButton() + self._lock_btn.setObjectName("layerLockBtn") + self._lock_btn.setCheckable(True) + self._lock_btn.setChecked(locked) + self._lock_btn.setFixedSize(16, 16) + self._lock_btn.setToolTip("Bloquear / Desbloquear capa") + self._lock_btn.clicked.connect(lambda v: self.lock_toggled.emit(name, v)) + + swatch = QLabel() + swatch.setObjectName("layerColorSwatch") + swatch.setFixedSize(12, 12) + swatch.setStyleSheet( + f"background-color:{color};border:1px solid #3a3f4b;border-radius:2px;" + ) + + lbl = QLabel(name) + lbl.setObjectName("layerName") + + layout.addWidget(self._vis_btn) + layout.addWidget(self._lock_btn) + layout.addWidget(swatch) + layout.addWidget(lbl) + layout.addStretch() + + +class LayersPanel(QWidget): + """Panel de capas — dock izquierdo (estilo DELFTship).""" + + layer_selected = Signal(str) + + def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.strings = strings + self.setObjectName("layersPanel") + self.setMinimumWidth(175) + self._build_ui() + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Cabecera ───────────────────────────────────────────── + header = QWidget() + header.setObjectName("layersPanelHeader") + header.setFixedHeight(28) + hl = QHBoxLayout(header) + hl.setContentsMargins(4, 2, 4, 2) + hl.setSpacing(2) + + sp = QStyle.StandardPixmap + for icon_sp, tip in ( + (sp.SP_FileIcon, "Nueva capa"), + (sp.SP_DialogSaveButton, "Duplicar capa"), + ): + btn = QToolButton() + btn.setIcon(_spi(icon_sp)) + btn.setFixedSize(22, 22) + btn.setToolTip(tip) + btn.setEnabled(False) + hl.addWidget(btn) + hl.addStretch() + layout.addWidget(header) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setObjectName("panelSep") + layout.addWidget(sep) + + # ── Lista de capas ──────────────────────────────────────── + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setObjectName("layersScrollArea") + scroll.setFrameShape(QFrame.Shape.NoFrame) + + container = QWidget() + container.setObjectName("layersContainer") + self._layer_layout = QVBoxLayout(container) + self._layer_layout.setContentsMargins(0, 0, 0, 0) + self._layer_layout.setSpacing(0) + + for name, color, visible, locked in _LAYER_PRESETS: + row = LayerRow(name, color, visible, locked) + row.visibility_toggled.connect( + lambda n, v: logger.debug("Capa '%s' visibilidad=%s", n, v) + ) + self._layer_layout.addWidget(row) + + self._layer_layout.addStretch() + scroll.setWidget(container) + layout.addWidget(scroll, 1) def set_project(self, project: Project) -> None: - """Actualiza el árbol con los datos del proyecto. Sprint 1.""" - pass # Se implementará en Sprint 1 + """Sprint 1: recargar capas desde los datos reales del proyecto.""" + pass +# ───────────────────────────────────────────────────────────────────────────── +# PANEL DE PROPIEDADES +# ───────────────────────────────────────────────────────────────────────────── + class PropertiesPanel(QWidget): - """Panel de propiedades del ítem seleccionado (derecha).""" + """Propiedades del elemento seleccionado — dock derecho.""" def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None: super().__init__(parent) @@ -256,133 +455,338 @@ class PropertiesPanel(QWidget): def _build_ui(self) -> None: layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - title = QLabel(f" {self.strings.get('panel_properties', 'Propiedades')}") - title.setFixedHeight(28) - title.setStyleSheet("background: #252830; color: #90caf9; font-weight: bold; padding-left: 8px;") - layout.addWidget(title) - - # Placeholder con dimensiones principales content = QWidget() - content_layout = QVBoxLayout(content) - content_layout.setContentsMargins(10, 10, 10, 10) - content_layout.setSpacing(6) + content.setObjectName("propContent") + cl = QVBoxLayout(content) + cl.setContentsMargins(10, 10, 10, 10) + cl.setSpacing(4) - props = [ - ("LOA", "— m"), - ("LPP", "— m"), - ("B", "— m"), - ("T", "— m"), - ("D", "— m"), - ("Δ", "— t"), - ("GMT", "— m"), - ] + sec = QLabel("Dimensiones principales") + sec.setObjectName("propSection") + cl.addWidget(sec) - mono_font = QFont("Consolas", 11) - for label, value in props: + mono = QFont("Consolas", 11) + for lbl_text, tip in [ + ("LOA", "Eslora total [m]"), + ("LPP", "Eslora entre perpendiculares [m]"), + ("B", "Manga [m]"), + ("T", "Calado [m]"), + ("D", "Puntal [m]"), + ("Δ", "Desplazamiento [t]"), + ("GMT", "Altura metacéntrica [m]"), + ]: row = QHBoxLayout() - lbl = QLabel(label) - lbl.setProperty("muted", True) - lbl.setFixedWidth(50) - val = QLabel(value) - val.setFont(mono_font) - row.addWidget(lbl) - row.addWidget(val) + row.setSpacing(8) + k = QLabel(lbl_text) + k.setObjectName("propKey") + k.setFixedWidth(40) + k.setToolTip(tip) + v = QLabel("—") + v.setObjectName("propVal") + v.setFont(mono) + v.setToolTip(tip) + row.addWidget(k) + row.addWidget(v) row.addStretch() - content_layout.addLayout(row) + cl.addLayout(row) - content_layout.addStretch() + cl.addStretch() layout.addWidget(content) -class CentralTabsWidget(QWidget): - """ - Widget central con tabs de vistas. +# ───────────────────────────────────────────────────────────────────────────── +# ÁREA CENTRAL — módulo activo +# ───────────────────────────────────────────────────────────────────────────── - En Sprint 0 muestra placeholders. - Los viewers reales (3D, líneas, etc.) se implementan en Sprint 1. +class ModuleArea(QStackedWidget): + """ + Área central con un módulo visible a la vez. + Índice 0 → FourViewport (vista de modelado estándar) + Índices 1..N → módulos de análisis / fabricación en pantalla completa """ - def __init__(self, strings: dict, parent: Optional[QWidget] = None) -> None: + # Índices de módulos + MOD_4VP = 0 # 4 viewports — vista de modelado (por defecto) + MOD_LINES = 1 + MOD_OFFSETS = 2 + MOD_CURVES = 3 + MOD_TANKS = 4 + MOD_CAPACITY = 5 + MOD_STABILITY = 6 + MOD_RESISTANCE = 7 + MOD_PROPULSION = 8 + MOD_VPP = 9 + MOD_SEAKEEPING = 10 + MOD_ELECTRICAL = 11 + MOD_FUEL = 12 + MOD_FRESHWATER = 13 + MOD_BILGE = 14 + MOD_FIREFIGHT = 15 + MOD_HVAC = 16 + MOD_SCANTLING = 17 + MOD_CNC = 18 + MOD_MOLDS = 19 + MOD_REPORT = 20 + MOD_ROUTING_PIPES = 21 + MOD_ROUTING_CABLES = 22 + + # (nombre, descripción) — None en índice 0 (FourViewport manejado aparte) + _MODULE_INFO: list[Optional[tuple[str, str]]] = [ + None, + ("Plano de Líneas", + "Visualización del plano de líneas tradicional (body plan, profile, plan)."), + ("Tabla de Offsets", + "Editor de offsets de secciones transversales."), + ("Curvas Hidrostáticas", + "Gráficas de hidrostáticos en función del calado."), + ("Modelado de Tanques", + "Definición geométrica de tanques y sentinas NURBS."), + ("Plan de Capacidad", + "Tablas de sondeo y plan de capacidad de tanques."), + ("Estabilidad GZ", + "Curva de brazos adrizantes GZ · Criterios IMO IS Code 2008."), + ("Resistencia al Avance", + "Métodos: Holtrop & Mennen · Savitsky · DSYHS · Hollenbach."), + ("Propulsión", + "Hélice de paso variable / fijo — Wageningen B-series."), + ("VPP Velero", + "Programa Velocidad-Potencia polar para veleros (DSYHS / ORC)."), + ("Seakeeping — Movimientos", + "Strip Theory (Salvesen-Tuck-Faltinsen). Espectros de respuesta."), + ("Balance Eléctrico (EPLA)", + "Lista de consumidores eléctricos y cálculo de generación."), + ("Sistema de Combustible", + "Diagrama de tuberías de combustible y cálculo de consumo."), + ("Sistema de Agua Dulce", + "Balance hídrico, tanques de agua dulce, producción."), + ("Sistema de Achique", + "Cálculo de bombas, diámetros de tuberías, caudal."), + ("Sistema Contra Incendios", + "Detección, supresión y cumplimiento SOLAS."), + ("HVAC", + "Sistema de climatización, renovación de aire, ventilación."), + ("Escantillado ISO 12215-5", + "Espesores de paneles, cuadernas y estructuras según ISO 12215."), + ("Fabricación CNC — Nesting", + "Optimización de corte de planchas, generación de G-code."), + ("Moldes FRP", + "Lofting de moldes, schedule de laminado, BOM de materiales."), + ("Generador de Reportes", + "Exporta hidrostáticos, estabilidad, escantillado a PDF / Excel."), + ("Routing de Tuberías 3D", + "Trazado de tuberías en 3D dentro del casco " + "con cálculo hidráulico e isométricos automáticos."), + ("Routing de Cableados 3D", + "Trazado de bandejas y cables eléctricos con gestión " + "de conductores y cálculo de caídas de tensión."), + ] + + def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) - self.strings = strings + + # Página 0: 4 viewports + self._four_vp = FourViewport() + self.addWidget(self._four_vp) + + # Páginas 1..N: módulos de análisis/fabricación + for info in self._MODULE_INFO[1:]: + if info: + name, desc = info + self.addWidget(self._make_placeholder(name, desc)) + + self.setCurrentIndex(self.MOD_4VP) + + @property + def four_viewport(self) -> FourViewport: + return self._four_vp + + @staticmethod + def _make_placeholder(name: str, desc: str = "") -> QWidget: + w = QWidget() + w.setObjectName("modulePlaceholder") + lo = QVBoxLayout(w) + lo.setAlignment(Qt.AlignmentFlag.AlignCenter) + lo.setSpacing(10) + + title = QLabel(name) + title.setObjectName("placeholderTitle") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + f = title.font() + f.setPointSize(16) + f.setBold(True) + title.setFont(f) + lo.addWidget(title) + + if desc: + desc_lbl = QLabel(desc) + desc_lbl.setObjectName("placeholderDesc") + desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + desc_lbl.setWordWrap(True) + desc_lbl.setMaximumWidth(520) + lo.addWidget(desc_lbl) + + sub = QLabel("Módulo en desarrollo — se implementará en el sprint correspondiente.") + sub.setObjectName("placeholderMsg") + sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + lo.addWidget(sub) + return w + + def activate(self, module_index: int) -> None: + if 0 <= module_index < self.count(): + self.setCurrentIndex(module_index) + + +# ───────────────────────────────────────────────────────────────────────────── +# RIBBON BAR +# ───────────────────────────────────────────────────────────────────────────── + +_RIBBON_TABS = [ + "Home", "Geometría", "Análisis", "Tanques", "Sistemas", "Fabricación", +] + + +class RibbonGroup(QFrame): + """Un grupo de botones de la ribbon con etiqueta de categoría.""" + + def __init__(self, title: str, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("ribbonGroup") + + lo = QVBoxLayout(self) + lo.setContentsMargins(3, 2, 3, 0) + lo.setSpacing(0) + + self._btn_row = QWidget() + self._btn_row.setObjectName("ribbonBtnArea") + self._btn_lo = QHBoxLayout(self._btn_row) + self._btn_lo.setContentsMargins(0, 0, 0, 0) + self._btn_lo.setSpacing(2) + + lbl = QLabel(title) + lbl.setObjectName("ribbonGroupTitle") + lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + lo.addWidget(self._btn_row) + lo.addWidget(lbl) + + def add_button( + self, + icon: QIcon, + text: str, + tooltip: str, + slot=None, + enabled: bool = True, + ) -> QToolButton: + btn = QToolButton() + btn.setObjectName("ribbonButton") + btn.setIcon(icon) + btn.setText(text) + btn.setToolTip(tooltip) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + btn.setIconSize(QSize(22, 22)) + btn.setEnabled(enabled) + if slot: + btn.clicked.connect(slot) + self._btn_lo.addWidget(btn) + return btn + + +class RibbonBar(QWidget): + """ + Ribbon bar con 6 pestañas funcionales. + Cada pestaña muestra grupos de botones relevantes para esa fase de trabajo. + """ + + TAB_HOME = 0 + TAB_GEOMETRY = 1 + TAB_ANALYSIS = 2 + TAB_TANKS = 3 + TAB_SYSTEMS = 4 + TAB_FABRICATION = 5 + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("ribbonBar") + self.setFixedHeight(92) + self._tab_btns: list[QToolButton] = [] self._build_ui() def _build_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) + main = QVBoxLayout(self) + main.setContentsMargins(0, 0, 0, 0) + main.setSpacing(0) - self.tabs = QTabWidget() - self.tabs.setTabPosition(QTabWidget.TabPosition.North) + # ── Fila de pestañas ────────────────────────────────────── + tab_row = QWidget() + tab_row.setObjectName("ribbonTabRow") + tab_row.setFixedHeight(26) + trl = QHBoxLayout(tab_row) + trl.setContentsMargins(2, 0, 0, 0) + trl.setSpacing(0) - tab_names = [ - ("tab_3d", "3D"), - ("tab_lines", "Líneas"), - ("tab_offsets", "Offsets"), - ("tab_curves", "Curvas Hidrost."), - ("tab_tanks", "Tanques"), - ("tab_capacity", "Capacidad"), - ("tab_stability", "Estabilidad GZ"), - ("tab_resistance", "Resistencia"), - ("tab_propulsion", "Propulsión"), - ("tab_vpp", "VPP Velero"), - ("tab_seakeeping", "Movimientos"), - ("tab_electrical", "Eléctrico"), - ("tab_fuel", "Combustible"), - ("tab_freshwater", "Agua Dulce"), - ("tab_bilge", "Achique"), - ("tab_firefighting", "C. Incendios"), - ("tab_hvac", "HVAC"), - ("tab_scantling", "Escantillado"), - ("tab_fabrication", "Fabricación CNC"), - ("tab_molds", "Moldes FRP"), - ("tab_report", "Reporte"), - ] + for i, name in enumerate(_RIBBON_TABS): + btn = QToolButton() + btn.setText(name) + btn.setObjectName("ribbonTabBtn") + btn.setCheckable(True) + btn.setChecked(i == 0) + btn.setFixedHeight(26) + btn.setMinimumWidth(80) + btn.clicked.connect(lambda _=False, idx=i: self._select_tab(idx)) + self._tab_btns.append(btn) + trl.addWidget(btn) - for key, default_name in tab_names: - name = self.strings.get(key, default_name) - placeholder = self._make_placeholder(name) - self.tabs.addTab(placeholder, name) + trl.addStretch() + main.addWidget(tab_row) - layout.addWidget(self.tabs) + # ── Stack de contenido por pestaña ──────────────────────── + self._stack = QStackedWidget() + self._stack.setObjectName("ribbonContent") + for _ in _RIBBON_TABS: + page = QWidget() + pl = QHBoxLayout(page) + pl.setContentsMargins(4, 2, 4, 0) + pl.setSpacing(0) + pl.setAlignment(Qt.AlignmentFlag.AlignLeft) + self._stack.addWidget(page) - @staticmethod - def _make_placeholder(tab_name: str) -> QWidget: - w = QWidget() - layout = QVBoxLayout(w) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + main.addWidget(self._stack) - icon_label = QLabel("🔜") - icon_label.setFont(QFont("Segoe UI Emoji", 48)) - icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + def _select_tab(self, idx: int) -> None: + for i, btn in enumerate(self._tab_btns): + btn.setChecked(i == idx) + self._stack.setCurrentIndex(idx) - msg = QLabel(f"Módulo: {tab_name}\nSe implementará en el sprint correspondiente.") - msg.setAlignment(Qt.AlignmentFlag.AlignCenter) - msg.setProperty("muted", True) - - layout.addWidget(icon_label) - layout.addWidget(msg) - return w + def new_group(self, tab_idx: int, title: str) -> RibbonGroup: + """Crea un grupo de botones en la pestaña indicada y devuelve el grupo.""" + page_lo: QHBoxLayout = self._stack.widget(tab_idx).layout() + group = RibbonGroup(title) + page_lo.addWidget(group) + sep = QFrame() + sep.setObjectName("ribbonSep") + sep.setFrameShape(QFrame.Shape.VLine) + page_lo.addWidget(sep) + return group -class NewProjectDialog(QMessageBox): - """Dialog simple de nuevo proyecto para Sprint 0.""" - pass - +# ───────────────────────────────────────────────────────────────────────────── +# VENTANA PRINCIPAL +# ───────────────────────────────────────────────────────────────────────────── class MainWindow(QMainWindow): """ Ventana principal de AR-ShipDesign. - Implementa el layout DELFTship-style con: - - Menú bar completo - - Toolbar principal - - Panel árbol de proyecto (izquierda, dock) - - Vista central con tabs de módulos - - Panel de propiedades (derecha, dock) - - Panel de hidrostáticos en vivo (inferior, fijo) - - Barra de estado + Arquitectura: + - Ribbon bar con 6 pestañas funcionales (Home / Geometría / Análisis / + Tanques / Sistemas / Fabricación) + - Menú completo con submenús por módulo + - Dock izquierdo: panel de capas (LayersPanel) + - Área central: 4 viewports de modelado (default) o módulo de análisis + - Dock derecho: propiedades del elemento seleccionado + - Panel inferior fijo (36 px): hidrostáticos en vivo + - Barra de estado """ def __init__(self) -> None: @@ -391,275 +795,400 @@ class MainWindow(QMainWindow): self._lang = get_language() self._strings = _load_i18n(self._lang) self._setup_ui() + self._setup_ribbon() self._setup_menu() - self._setup_toolbar() self._setup_status_bar() self._restore_geometry() self._update_title() logger.info("MainWindow inicializada") - # ────────────────────────────────────────────── - # SETUP UI - # ────────────────────────────────────────────── + # ───────────────────────────────────────────────────────── + # SETUP + # ───────────────────────────────────────────────────────── def _setup_ui(self) -> None: self.setMinimumSize(1100, 700) - # Panel árbol proyecto (dock izquierda) - self._project_tree = ProjectTreePanel(self._strings) - dock_tree = QDockWidget(self._strings.get("panel_project", "Proyecto"), self) - dock_tree.setObjectName("dockProjectTree") - dock_tree.setWidget(self._project_tree) - dock_tree.setFeatures( + # Área central + self._module_area = ModuleArea() + self.setCentralWidget(self._module_area) + + # Dock izquierdo — capas + self._layers_panel = LayersPanel(self._strings) + self._dock_layers = QDockWidget("Capas", self) + self._dock_layers.setObjectName("dockLayers") + self._dock_layers.setWidget(self._layers_panel) + self._dock_layers.setFeatures( QDockWidget.DockWidgetFeature.DockWidgetMovable | - QDockWidget.DockWidgetFeature.DockWidgetFloatable + QDockWidget.DockWidgetFeature.DockWidgetFloatable | + QDockWidget.DockWidgetFeature.DockWidgetClosable ) - dock_tree.setMinimumWidth(200) - self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock_tree) + self._dock_layers.setMinimumWidth(175) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._dock_layers) - # Panel propiedades (dock derecha) - self._properties_panel = PropertiesPanel(self._strings) - dock_props = QDockWidget(self._strings.get("panel_properties", "Propiedades"), self) - dock_props.setObjectName("dockProperties") - dock_props.setWidget(self._properties_panel) - dock_props.setFeatures( + # Dock derecho — propiedades + self._props_panel = PropertiesPanel(self._strings) + self._dock_props = QDockWidget("Propiedades", self) + self._dock_props.setObjectName("dockProperties") + self._dock_props.setWidget(self._props_panel) + self._dock_props.setFeatures( QDockWidget.DockWidgetFeature.DockWidgetMovable | - QDockWidget.DockWidgetFeature.DockWidgetFloatable + QDockWidget.DockWidgetFeature.DockWidgetFloatable | + QDockWidget.DockWidgetFeature.DockWidgetClosable ) - dock_props.setMinimumWidth(220) - self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock_props) + self._dock_props.setMinimumWidth(200) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._dock_props) - # Widget central con tabs - self._central_tabs = CentralTabsWidget(self._strings) - self.setCentralWidget(self._central_tabs) - - # Panel hidrostáticos (dock inferior, no ocultable) - self._hydro_panel = HydrostaticsPanel(self._strings) - dock_hydro = QDockWidget(self._strings.get("panel_hydrostatics", "Hidrostáticos"), self) + # Dock inferior — hidrostáticos (sin barra de título) + self._hydro = HydrostaticsPanel(self._strings) + dock_hydro = QDockWidget(self) dock_hydro.setObjectName("dockHydrostatics") - dock_hydro.setWidget(self._hydro_panel) + dock_hydro.setWidget(self._hydro) dock_hydro.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) - dock_hydro.setTitleBarWidget(QWidget()) # Oculta la barra de título del dock + dock_hydro.setTitleBarWidget(QWidget()) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dock_hydro) - # Conectar señales - self._project_tree.item_selected.connect(self._on_tree_item_selected) + def _setup_ribbon(self) -> None: + sp = QStyle.StandardPixmap + M = ModuleArea + + self._ribbon = RibbonBar() + + # Envuelto en QToolBar para integración correcta con QMainWindow + tb = QToolBar("Ribbon", self) + tb.setObjectName("ribbonToolbar") + tb.setMovable(False) + tb.setFloatable(False) + tb.setContentsMargins(0, 0, 0, 0) + tb.addWidget(self._ribbon) + self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb) + + # ── Tab HOME ───────────────────────────────────────────── + g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Archivo") + g.add_button(_spi(sp.SP_FileIcon), "Nuevo", "Nuevo proyecto Ctrl+N", + self._on_new_project) + g.add_button(_spi(sp.SP_DirOpenIcon), "Abrir", "Abrir proyecto Ctrl+O", + self._on_open_project) + g.add_button(_spi(sp.SP_DialogSaveButton), "Guardar", "Guardar Ctrl+S", + self._on_save_project) + + g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Editar") + g.add_button(_spi(sp.SP_ArrowBack), "Deshacer", "Deshacer Ctrl+Z", enabled=False) + g.add_button(_spi(sp.SP_ArrowForward), "Rehacer", "Rehacer Ctrl+Y", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_HOME, "Vistas") + g.add_button(_spi(sp.SP_DesktopIcon), "4 Vistas", "4 Viewports F2", + lambda: self._module_area.activate(M.MOD_4VP)) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Líneas", "Plano de Líneas F3", + lambda: self._module_area.activate(M.MOD_LINES)) + g.add_button(_spi(sp.SP_FileDialogListView), "Offsets", "Tabla de Offsets F4", + lambda: self._module_area.activate(M.MOD_OFFSETS)) + + # ── Tab GEOMETRÍA ───────────────────────────────────────── + g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Nuevo") + g.add_button(_spi(sp.SP_FileIcon), "Asistente", "Asistente de embarcación", enabled=False) + g.add_button(_spi(sp.SP_FileIcon), "Casco NURBS", "Nuevo casco NURBS", enabled=False) + g.add_button(_spi(sp.SP_FileIcon), "Apéndice", "Añadir apéndice", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Edición NURBS") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Pts. Ctrl.", "Editar puntos de control", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Extruir", "Extruir sección", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espejo", "Espejo por eje de crujía", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Lackenby", "Transformación de Lackenby", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Importar") + g.add_button(_spi(sp.SP_DirOpenIcon), "Offsets", "Importar tabla de offsets (.txt / .csv)", enabled=False) + g.add_button(_spi(sp.SP_DirOpenIcon), "DXF", "Importar plano DXF", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_GEOMETRY, "Exportar") + g.add_button(_spi(sp.SP_DialogSaveButton), "IGES", "Exportar IGES", enabled=False) + g.add_button(_spi(sp.SP_DialogSaveButton), "STEP", "Exportar STEP", enabled=False) + g.add_button(_spi(sp.SP_DialogSaveButton), "DXF", "Exportar DXF", enabled=False) + + # ── Tab ANÁLISIS ────────────────────────────────────────── + g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular hidrostáticos", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas", + lambda: self._module_area.activate(M.MOD_CURVES), False) + + g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática", + lambda: self._module_area.activate(M.MOD_STABILITY), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Resistencia") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Holtrop", "Holtrop & Mennen", + lambda: self._module_area.activate(M.MOD_RESISTANCE), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Savitsky", "Savitsky (planeo)", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "VPP", "VPP Velero / DSYHS", + lambda: self._module_area.activate(M.MOD_VPP), False) + + g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Seakeeping") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "STF", "Strip Theory (STF)", + lambda: self._module_area.activate(M.MOD_SEAKEEPING), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Espectro", "Espectro de respuesta", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estructura") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "ISO 12215", "Escantillado ISO 12215-5", + lambda: self._module_area.activate(M.MOD_SCANTLING), False) + + # ── Tab TANQUES ─────────────────────────────────────────── + g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Tanques") + g.add_button(_spi(sp.SP_FileIcon), "Nuevo Tq.", "Definir nuevo tanque", + lambda: self._module_area.activate(M.MOD_TANKS), False) + g.add_button(_spi(sp.SP_FileIcon), "Modelar", "Modelar tanque NURBS", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_TANKS, "Casos de Carga") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Nuevo caso", "Definir caso de carga", enabled=False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Sondeos", "Tablas de sondeo", + lambda: self._module_area.activate(M.MOD_CAPACITY), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calc. KG", "Calcular KG por caso", enabled=False) + + # ── Tab SISTEMAS ────────────────────────────────────────── + g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Eléctrico") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "EPLA", "Balance eléctrico (EPLA)", + lambda: self._module_area.activate(M.MOD_ELECTRICAL), False) + + g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Fluidos") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Combustible", "Sistema de combustible", + lambda: self._module_area.activate(M.MOD_FUEL), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Agua Dulce", "Sistema de agua dulce", + lambda: self._module_area.activate(M.MOD_FRESHWATER), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Achique", "Sistema de achique", + lambda: self._module_area.activate(M.MOD_BILGE), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "C. Incendio", "Sistema contra incendios", + lambda: self._module_area.activate(M.MOD_FIREFIGHT), False) + + g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Routing 3D") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Tuberías", "Routing de tuberías 3D", + lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Cableados", "Routing de cableados 3D", + lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), False) + + g = self._ribbon.new_group(RibbonBar.TAB_SYSTEMS, "Clima / Control") + g.add_button(_spi(sp.SP_FileDialogDetailedView), "HVAC", "Sistema HVAC", + lambda: self._module_area.activate(M.MOD_HVAC), False) + g.add_button(_spi(sp.SP_FileDialogDetailedView), "Gobierno", "Sistema de gobierno", enabled=False) + + # ── Tab FABRICACIÓN ─────────────────────────────────────── + g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "CNC") + g.add_button(_spi(sp.SP_FileDialogContentsView), "Materiales", "Estimación de materiales", enabled=False) + g.add_button(_spi(sp.SP_FileDialogContentsView), "Nesting", "Optimización de cortes (nesting)", + lambda: self._module_area.activate(M.MOD_CNC), False) + g.add_button(_spi(sp.SP_FileDialogContentsView), "G-code", "Generar G-code", enabled=False) + g.add_button(_spi(sp.SP_FileDialogContentsView), "Post-Proc.", "Configurar post-procesador CNC", enabled=False) + + g = self._ribbon.new_group(RibbonBar.TAB_FABRICATION, "Moldes FRP") + g.add_button(_spi(sp.SP_FileDialogContentsView), "Lofting", "Lofting del molde", + lambda: self._module_area.activate(M.MOD_MOLDS), False) + g.add_button(_spi(sp.SP_FileDialogContentsView), "Laminado", "Schedule de laminado", enabled=False) + g.add_button(_spi(sp.SP_FileDialogContentsView), "Resina", "Calculadora de resina", enabled=False) + g.add_button(_spi(sp.SP_FileDialogContentsView), "BOM", "BOM de materiales", enabled=False) def _setup_menu(self) -> None: - s = self._strings - menubar = self.menuBar() + mb = self.menuBar() + sp = QStyle.StandardPixmap + M = ModuleArea - # ── ARCHIVO ── - menu_file = menubar.addMenu(s.get("menu_file", "Archivo")) - - act_new = QAction(s.get("file_new", "Nuevo proyecto"), self) - act_new.setShortcut(QKeySequence.StandardKey.New) - act_new.triggered.connect(self._on_new_project) - menu_file.addAction(act_new) - - act_open = QAction(s.get("file_open", "Abrir..."), self) - act_open.setShortcut(QKeySequence.StandardKey.Open) - act_open.triggered.connect(self._on_open_project) - menu_file.addAction(act_open) - - menu_file.addSeparator() - - act_save = QAction(s.get("file_save", "Guardar"), self) - act_save.setShortcut(QKeySequence.StandardKey.Save) - act_save.triggered.connect(self._on_save_project) - menu_file.addAction(act_save) - - act_save_as = QAction(s.get("file_save_as", "Guardar como..."), self) - act_save_as.setShortcut(QKeySequence("Ctrl+Shift+S")) - act_save_as.triggered.connect(self._on_save_as_project) - menu_file.addAction(act_save_as) - - menu_file.addSeparator() - - # Recientes - self._recent_menu = menu_file.addMenu(s.get("file_recent", "Recientes")) + # ── ARCHIVO ──────────────────────────────────────────────── + m = mb.addMenu("Archivo") + self._add_action(m, "Nuevo proyecto", QKeySequence.StandardKey.New, self._on_new_project, _spi(sp.SP_FileIcon)) + self._add_action(m, "Abrir...", QKeySequence.StandardKey.Open, self._on_open_project, _spi(sp.SP_DirOpenIcon)) + m.addSeparator() + self._add_action(m, "Guardar", QKeySequence.StandardKey.Save, self._on_save_project, _spi(sp.SP_DialogSaveButton)) + self._add_action(m, "Guardar como...", QKeySequence("Ctrl+Shift+S"), self._on_save_as) + m.addSeparator() + self._recent_menu = m.addMenu("Recientes") self._update_recent_menu() + m.addSeparator() + self._add_action(m, "Salir", QKeySequence("Alt+F4"), self.close) - menu_file.addSeparator() + # ── EDITAR ───────────────────────────────────────────────── + m = mb.addMenu("Editar") + self._act_undo = self._add_action(m, "Deshacer", QKeySequence.StandardKey.Undo, enabled=False) + self._act_redo = self._add_action(m, "Rehacer", QKeySequence.StandardKey.Redo, enabled=False) + m.addSeparator() + self._add_action(m, "Preferencias...", slot=self._on_preferences) - act_exit = QAction(s.get("file_exit", "Salir"), self) - act_exit.setShortcut(QKeySequence("Alt+F4")) - act_exit.triggered.connect(self.close) - menu_file.addAction(act_exit) + # ── VER ──────────────────────────────────────────────────── + m = mb.addMenu("Ver") + self._add_action(m, "4 Viewports", QKeySequence("F2"), lambda: self._module_area.activate(M.MOD_4VP)) + self._add_action(m, "Plano de Líneas", QKeySequence("F3"), lambda: self._module_area.activate(M.MOD_LINES)) + self._add_action(m, "Tabla de Offsets", QKeySequence("F4"), lambda: self._module_area.activate(M.MOD_OFFSETS)) + m.addSeparator() + self._add_action(m, "Panel Capas", + slot=lambda: self._dock_layers.setVisible(not self._dock_layers.isVisible())) + self._add_action(m, "Panel Propiedades", + slot=lambda: self._dock_props.setVisible(not self._dock_props.isVisible())) + m.addSeparator() + self._add_action(m, "Tema claro / oscuro", slot=self._on_toggle_theme) - # ── EDITAR ── - menu_edit = menubar.addMenu(s.get("menu_edit", "Editar")) + # ── MODELO ──────────────────────────────────────────────── + m = mb.addMenu("Modelo") + self._add_action(m, "Asistente de embarcación...", enabled=False) + self._add_action(m, "Nuevo casco NURBS...", enabled=False) + m.addSeparator() + self._add_action(m, "Importar offsets (.txt / .csv)...", enabled=False) + self._add_action(m, "Importar DXF...", enabled=False) + self._add_action(m, "Exportar IGES...", enabled=False) + self._add_action(m, "Exportar STEP...", enabled=False) - act_undo = QAction(s.get("edit_undo", "Deshacer"), self) - act_undo.setShortcut(QKeySequence.StandardKey.Undo) - act_undo.setEnabled(False) # Sprint 1 - menu_edit.addAction(act_undo) + # ── ANÁLISIS ────────────────────────────────────────────── + m = mb.addMenu("Análisis") - act_redo = QAction(s.get("edit_redo", "Rehacer"), self) - act_redo.setShortcut(QKeySequence.StandardKey.Redo) - act_redo.setEnabled(False) # Sprint 1 - menu_edit.addAction(act_redo) + sm = m.addMenu("Hidrostática") + self._add_action(sm, "Calcular hidrostáticos", enabled=False) + self._add_action(sm, "Curvas hidrostáticas", slot=lambda: self._module_area.activate(M.MOD_CURVES), enabled=False) - menu_edit.addSeparator() + sm = m.addMenu("Estabilidad") + self._add_action(sm, "Curva GZ — Estabilidad estática", slot=lambda: self._module_area.activate(M.MOD_STABILITY), enabled=False) + self._add_action(sm, "Criterios IMO IS Code 2008", enabled=False) + self._add_action(sm, "Criterio de viento A.749(18)", enabled=False) + self._add_action(sm, "Estabilidad en avería (SOLAS 2009)", enabled=False) - act_prefs = QAction(s.get("edit_preferences", "Preferencias..."), self) - act_prefs.triggered.connect(self._on_preferences) - menu_edit.addAction(act_prefs) + sm = m.addMenu("Resistencia y Propulsión") + self._add_action(sm, "Resistencia (Holtrop & Mennen / Savitsky)", slot=lambda: self._module_area.activate(M.MOD_RESISTANCE), enabled=False) + self._add_action(sm, "Propulsión — Wageningen B-series", slot=lambda: self._module_area.activate(M.MOD_PROPULSION), enabled=False) + self._add_action(sm, "VPP Velero (DSYHS)", slot=lambda: self._module_area.activate(M.MOD_VPP), enabled=False) - # ── VER ── - menu_view = menubar.addMenu(s.get("menu_view", "Ver")) - act_theme = QAction("Cambiar tema (claro/oscuro)", self) - act_theme.triggered.connect(self._on_toggle_theme) - menu_view.addAction(act_theme) + sm = m.addMenu("Seakeeping") + self._add_action(sm, "Movimientos (Strip Theory STF)", slot=lambda: self._module_area.activate(M.MOD_SEAKEEPING), enabled=False) + self._add_action(sm, "Espectro de respuesta", enabled=False) - # ── MODELO ── - menu_model = menubar.addMenu(s.get("menu_model", "Modelo")) - menu_model.addAction("Nuevo casco... (Sprint 1)") - menu_model.addAction("Wizard de embarcación... (Sprint 1)") - menu_model.addAction("Importar offsets... (Sprint 1)") - menu_model.addAction("Importar DXF... (Sprint 1)") + m.addSeparator() + self._add_action(m, "Escantillado ISO 12215-5", slot=lambda: self._module_area.activate(M.MOD_SCANTLING), enabled=False) - # ── ANÁLISIS ── - menu_analysis = menubar.addMenu(s.get("menu_analysis", "Análisis")) - menu_analysis.addAction("Hidrostáticos (Sprint 2)") - menu_analysis.addAction("Estabilidad GZ (Sprint 3)") - menu_analysis.addAction("Escantillado ISO 12215 (Sprint 2.5)") - menu_analysis.addAction("Resistencia y Propulsión (Sprint 5)") - menu_analysis.addAction("VPP Velero (Sprint 6)") - menu_analysis.addAction("Movimientos / Seakeeping (Sprint 9)") + # ── TANQUES Y CARGA ──────────────────────────────────────── + m = mb.addMenu("Tanques") + self._add_action(m, "Modelado de tanques", slot=lambda: self._module_area.activate(M.MOD_TANKS), enabled=False) + self._add_action(m, "Plan de capacidad", slot=lambda: self._module_area.activate(M.MOD_CAPACITY), enabled=False) + self._add_action(m, "Tablas de sondeo", enabled=False) + self._add_action(m, "Casos de carga / KG", enabled=False) - # ── SISTEMAS ── - menu_systems = menubar.addMenu(s.get("menu_systems", "Sistemas")) - for sys_name in ["Eléctrico (Sprint 7)", "Combustible (Sprint 7)", - "Agua Dulce (Sprint 7)", "Achique (Sprint 7)", - "Lastre (Sprint 7)", "C. Incendios (Sprint 8)", - "HVAC (Sprint 8)", "Gobierno (Sprint 8)"]: - menu_systems.addAction(sys_name) + # ── SISTEMAS ────────────────────────────────────────────── + m = mb.addMenu("Sistemas") + self._add_action(m, "Balance eléctrico (EPLA)", slot=lambda: self._module_area.activate(M.MOD_ELECTRICAL), enabled=False) + self._add_action(m, "Sistema de combustible", slot=lambda: self._module_area.activate(M.MOD_FUEL), enabled=False) + self._add_action(m, "Sistema de agua dulce", slot=lambda: self._module_area.activate(M.MOD_FRESHWATER), enabled=False) + self._add_action(m, "Sistema de achique", slot=lambda: self._module_area.activate(M.MOD_BILGE), enabled=False) + m.addSeparator() + self._add_action(m, "Sistema contra incendios", slot=lambda: self._module_area.activate(M.MOD_FIREFIGHT), enabled=False) + self._add_action(m, "HVAC", slot=lambda: self._module_area.activate(M.MOD_HVAC), enabled=False) + self._add_action(m, "Sistema de gobierno", enabled=False) + self._add_action(m, "Lastre y amarre", enabled=False) + m.addSeparator() + sm = m.addMenu("Routing 3D") + self._add_action(sm, "Routing de tuberías", slot=lambda: self._module_area.activate(M.MOD_ROUTING_PIPES), enabled=False) + self._add_action(sm, "Routing de cableados", slot=lambda: self._module_area.activate(M.MOD_ROUTING_CABLES), enabled=False) - # ── FABRICACIÓN ── - menu_fab = menubar.addMenu(s.get("menu_fabrication", "Fabricación")) - menu_fab.addAction("Estimación de material (Sprint 13)") - menu_fab.addAction("Nesting / Optimización de cortes (Sprint 13)") - menu_fab.addAction("Generar G-code CNC (Sprint 13)") - menu_fab.addSeparator() - menu_fab.addAction("Moldes FRP — Lofting (Sprint 13B)") - menu_fab.addAction("Moldes FRP — Schedule laminado (Sprint 13B)") - menu_fab.addAction("Moldes FRP — BOM materiales (Sprint 13B)") + # ── FABRICACIÓN ──────────────────────────────────────────── + m = mb.addMenu("Fabricación") + sm = m.addMenu("CNC") + self._add_action(sm, "Estimación de materiales", enabled=False) + self._add_action(sm, "Optimización de cortes (Nesting)", slot=lambda: self._module_area.activate(M.MOD_CNC), enabled=False) + self._add_action(sm, "Generar G-code", enabled=False) + self._add_action(sm, "Configurar post-procesador CNC", enabled=False) - # ── REPORTES ── - menu_reports = menubar.addMenu(s.get("menu_reports", "Reportes")) - menu_reports.addAction("Reporte Hidrostático (Sprint 10)") - menu_reports.addAction("Cuaderno de Estabilidad (Sprint 10)") - menu_reports.addAction("Plano de Líneas (Sprint 10)") - menu_reports.addAction("Reporte Escantillado (Sprint 10)") - menu_reports.addAction("Balance Eléctrico (Sprint 10)") + sm = m.addMenu("Moldes FRP") + self._add_action(sm, "Lofting del molde", slot=lambda: self._module_area.activate(M.MOD_MOLDS), enabled=False) + self._add_action(sm, "Schedule de laminado", enabled=False) + self._add_action(sm, "Calculadora de resina", enabled=False) + self._add_action(sm, "BOM de materiales", enabled=False) - # ── AYUDA ── - menu_help = menubar.addMenu(s.get("menu_help", "Ayuda")) - act_about = QAction(s.get("about_title", "Acerca de..."), self) - act_about.triggered.connect(self._on_about) - menu_help.addAction(act_about) + # ── REPORTES ─────────────────────────────────────────────── + m = mb.addMenu("Reportes") + self._add_action(m, "Reporte hidrostático", slot=lambda: self._module_area.activate(M.MOD_REPORT), enabled=False) + self._add_action(m, "Cuaderno de estabilidad", enabled=False) + self._add_action(m, "Plano de líneas (PDF / DXF)", enabled=False) + self._add_action(m, "Reporte de escantillado", enabled=False) + self._add_action(m, "Balance eléctrico (Excel)", enabled=False) + self._add_action(m, "Plan de capacidad", enabled=False) - def _setup_toolbar(self) -> None: - tb = QToolBar("Principal", self) - tb.setObjectName("mainToolbar") - tb.setMovable(False) - self.addToolBar(tb) + # ── AYUDA ───────────────────────────────────────────────── + m = mb.addMenu("Ayuda") + self._add_action(m, "Acerca de AR-ShipDesign...", slot=self._on_about) - buttons = [ - ("🗎", "Nuevo proyecto (Ctrl+N)", self._on_new_project), - ("📂", "Abrir proyecto (Ctrl+O)", self._on_open_project), - ("💾", "Guardar (Ctrl+S)", self._on_save_project), - ] - for icon_text, tip, slot in buttons: - btn = tb.addAction(icon_text) - btn.setToolTip(tip) - btn.triggered.connect(slot) - - tb.addSeparator() - btn_undo = tb.addAction("↩") - btn_undo.setToolTip("Deshacer (Ctrl+Z)") - btn_undo.setEnabled(False) - - btn_redo = tb.addAction("↪") - btn_redo.setToolTip("Rehacer (Ctrl+Y)") - btn_redo.setEnabled(False) - - tb.addSeparator() - btn_wizard = tb.addAction("🚢 Wizard") - btn_wizard.setToolTip("Wizard de nueva embarcación (Sprint 1)") - btn_wizard.setEnabled(False) - - tb.addSeparator() - btn_theme = tb.addAction("☀/🌙") - btn_theme.setToolTip("Cambiar tema claro/oscuro") - btn_theme.triggered.connect(self._on_toggle_theme) - - # Separador flexible - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - tb.addWidget(spacer) - - # Selector de unidades - tb.addWidget(QLabel(" Unidades: ")) - self._units_label = QLabel("SI") - self._units_label.setStyleSheet("color: #90caf9; font-weight: bold; margin-right: 8px;") - tb.addWidget(self._units_label) - - # Idioma - tb.addWidget(QLabel(" 🌍 ")) - self._lang_label = QLabel(self._lang.upper()) - self._lang_label.setStyleSheet("color: #90caf9; font-weight: bold; margin-right: 8px;") - tb.addWidget(self._lang_label) + def _add_action( + self, + menu, + text: str, + shortcut=None, + slot=None, + icon: Optional[QIcon] = None, + enabled: bool = True, + ) -> QAction: + a = QAction(text, self) + if icon: + a.setIcon(icon) + if shortcut: + a.setShortcut(shortcut) + if slot: + a.triggered.connect(slot) + a.setEnabled(enabled) + menu.addAction(a) + return a def _setup_status_bar(self) -> None: sb = self.statusBar() - sb.showMessage(self._strings.get("status_ready", "Listo")) + sb.showMessage("Listo") - self._status_version = QLabel(f" AR-ShipDesign v{__version__} ") - sb.addPermanentWidget(self._status_version) + # Unidades + lbl_u = QLabel("Unidades: ") + lbl_u.setObjectName("statusLabel") + self._units_lbl = QLabel("SI") + self._units_lbl.setObjectName("statusValue") - # ────────────────────────────────────────────── + # Idioma + lbl_l = QLabel(" Idioma: ") + lbl_l.setObjectName("statusLabel") + self._lang_lbl = QLabel(self._lang.upper()) + self._lang_lbl.setObjectName("statusValue") + + # Versión + ver = QLabel(f" AR-ShipDesign v{__version__} ") + ver.setObjectName("statusVersion") + + sb.addPermanentWidget(lbl_u) + sb.addPermanentWidget(self._units_lbl) + sb.addPermanentWidget(lbl_l) + sb.addPermanentWidget(self._lang_lbl) + sb.addPermanentWidget(ver) + + # ───────────────────────────────────────────────────────── # ACCIONES DE PROYECTO - # ────────────────────────────────────────────── + # ───────────────────────────────────────────────────────── def _on_new_project(self) -> None: - if self._project and self._project.is_modified: - if not self._ask_save_changes(): - return + if self._project and self._project.is_modified and not self._ask_save(): + return self._project = Project.new("Proyecto sin título") self._on_project_loaded() self.statusBar().showMessage("Nuevo proyecto creado") def _on_open_project(self) -> None: - if self._project and self._project.is_modified: - if not self._ask_save_changes(): - return - + if self._project and self._project.is_modified and not self._ask_save(): + return path, _ = QFileDialog.getOpenFileName( - self, - "Abrir proyecto AR-ShipDesign", - str(Path.home()), + self, "Abrir proyecto", str(Path.home()), "Proyectos AR-ShipDesign (*.arsd);;Todos los archivos (*)", ) if not path: return - try: self._project = Project.load(Path(path)) add_recent_file(path) self._update_recent_menu() self._on_project_loaded() - self.statusBar().showMessage(f"Proyecto abierto: {path}") + self.statusBar().showMessage(f"Abierto: {path}") except Exception as e: - QMessageBox.critical(self, "Error al abrir", f"No se pudo abrir el proyecto:\n{e}") - logger.error("Error abriendo proyecto: %s", e) + QMessageBox.critical(self, "Error al abrir", str(e)) def _on_save_project(self) -> None: - if self._project is None: + if not self._project: return - if self._project.path is None: - self._on_save_as_project() + if not self._project.path: + self._on_save_as() return try: self._project.save() @@ -668,12 +1197,11 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Error al guardar", str(e)) - def _on_save_as_project(self) -> None: - if self._project is None: + def _on_save_as(self) -> None: + if not self._project: return path, _ = QFileDialog.getSaveFileName( - self, - "Guardar proyecto como...", + self, "Guardar como...", str(Path.home() / f"{self._project.name}.arsd"), "Proyectos AR-ShipDesign (*.arsd)", ) @@ -689,59 +1217,52 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Error al guardar", str(e)) def _on_project_loaded(self) -> None: - """Callback cuando se carga o crea un proyecto.""" self._update_title() - self._project_tree.set_project(self._project) + self._layers_panel.set_project(self._project) - def _ask_save_changes(self) -> bool: - """Pregunta si guardar antes de cerrar/nuevo. Retorna True si se puede continuar.""" + def _ask_save(self) -> bool: reply = QMessageBox.question( - self, - "Cambios sin guardar", - f"El proyecto '{self._project.name}' tiene cambios sin guardar.\n¿Desea guardar antes de continuar?", - QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, + self, "Cambios sin guardar", + f"'{self._project.name}' tiene cambios sin guardar. ¿Guardar antes de continuar?", + QMessageBox.StandardButton.Save | + QMessageBox.StandardButton.Discard | + QMessageBox.StandardButton.Cancel, ) if reply == QMessageBox.StandardButton.Save: self._on_save_project() return True - elif reply == QMessageBox.StandardButton.Discard: - return True - return False # Cancel + return reply == QMessageBox.StandardButton.Discard - # ────────────────────────────────────────────── + # ───────────────────────────────────────────────────────── # ACCIONES DE UI - # ────────────────────────────────────────────── - - def _on_tree_item_selected(self, name: str) -> None: - self.statusBar().showMessage(f"Seleccionado: {name}") + # ───────────────────────────────────────────────────────── def _on_preferences(self) -> None: - QMessageBox.information(self, "Preferencias", "Diálogo de preferencias — Sprint 1") + QMessageBox.information(self, "Preferencias", "Disponible en Sprint 1") def _on_toggle_theme(self) -> None: current = get_theme() new_theme = "light" if current == "dark" else "dark" set_theme(new_theme) - self._apply_theme(new_theme) - - def _apply_theme(self, theme: str) -> None: - qss_path = Path(__file__).parent / "themes" / f"{theme}.qss" + qss_path = Path(__file__).parent / "themes" / f"{new_theme}.qss" try: - qss = qss_path.read_text(encoding="utf-8") - QApplication.instance().setStyleSheet(qss) + QApplication.instance().setStyleSheet( + qss_path.read_text(encoding="utf-8") + ) except Exception as e: - logger.warning("No se pudo aplicar el tema %s: %s", theme, e) + logger.warning("Tema %s no disponible: %s", new_theme, e) def _on_about(self) -> None: QMessageBox.about( - self, - self._strings.get("about_title", "Acerca de AR-ShipDesign"), - f"""AR-ShipDesign v{__version__}
-Software profesional de diseño naval.

-Motor geométrico: NURBS (geomdl)
-Visualización 3D: PyVista + VTK
-Estándares: ISO 12215, IMO IS Code 2008

-{self._strings.get("about_copyright", "Copyright © 2025 Álvaro Rodríguez")}""", + self, "Acerca de AR-ShipDesign", + f"AR-ShipDesign   v{__version__}

" + "Software profesional de diseño naval.

" + "Motor geométrico: NURBS (geomdl)
" + "Visualización 3D: PyVista + VTK
" + "Estabilidad: IMO IS Code 2008 · A.749(18) · SOLAS 2009
" + "Resistencia: Holtrop & Mennen · Savitsky · DSYHS
" + "Escantillado: ISO 12215-5

" + f"{self._strings.get('about_copyright', 'Copyright © 2025 Álvaro Romero')}", ) def _update_title(self) -> None: @@ -754,34 +1275,33 @@ Estándares: ISO 12215, IMO IS Code 2008

self._recent_menu.clear() recent = get_recent_files() if not recent: - act = QAction("(sin archivos recientes)", self) - act.setEnabled(False) - self._recent_menu.addAction(act) + a = QAction("(sin archivos recientes)", self) + a.setEnabled(False) + self._recent_menu.addAction(a) return for path in recent: - act = QAction(Path(path).name, self) - act.setToolTip(path) - act.triggered.connect(lambda checked=False, p=path: self._open_recent(p)) - self._recent_menu.addAction(act) + a = QAction(Path(path).name, self) + a.setToolTip(path) + a.triggered.connect(lambda _=False, p=path: self._open_recent(p)) + self._recent_menu.addAction(a) def _open_recent(self, path: str) -> None: - if self._project and self._project.is_modified: - if not self._ask_save_changes(): - return + if self._project and self._project.is_modified and not self._ask_save(): + return try: self._project = Project.load(Path(path)) add_recent_file(path) self._on_project_loaded() except Exception as e: - QMessageBox.critical(self, "Error", f"No se pudo abrir:\n{e}") + QMessageBox.critical(self, "Error", str(e)) - # ────────────────────────────────────────────── - # GEOMETRÍA Y ESTADO - # ────────────────────────────────────────────── + # ───────────────────────────────────────────────────────── + # GEOMETRÍA DE VENTANA + # ───────────────────────────────────────────────────────── def _restore_geometry(self) -> None: s = get_settings() - geom = s.value("ui/windowGeometry") + geom = s.value("ui/windowGeometry") state = s.value("ui/windowState") if geom: self.restoreGeometry(geom) @@ -792,10 +1312,9 @@ Estándares: ISO 12215, IMO IS Code 2008

self.restoreState(state) def closeEvent(self, event) -> None: - if self._project and self._project.is_modified: - if not self._ask_save_changes(): - event.ignore() - return + if self._project and self._project.is_modified and not self._ask_save(): + event.ignore() + return s = get_settings() s.setValue("ui/windowGeometry", self.saveGeometry()) s.setValue("ui/windowState", self.saveState()) diff --git a/arshipdesign/ui/themes/dark.qss b/arshipdesign/ui/themes/dark.qss index 43f7948..f93ed8a 100644 --- a/arshipdesign/ui/themes/dark.qss +++ b/arshipdesign/ui/themes/dark.qss @@ -1,162 +1,616 @@ -/* AR-ShipDesign — Tema Oscuro */ +/* ============================================================ + AR-ShipDesign — Tema Oscuro Profesional + Estilo: ingeniería CAD / naval / Office dark mode + Paleta: #1e2128 fondo, #252830 panel, #2d3139 elevado, + #0d99ff acento, #e8eaf0 texto, #8b919e texto muted + ============================================================ */ + +/* ─── BASE ─────────────────────────────────────────────────── */ + +* { + outline: none; +} QMainWindow, QDialog, QWidget { background-color: #1e2128; - color: #e0e0e0; + color: #e8eaf0; font-family: "Segoe UI", Arial, sans-serif; font-size: 13px; } +/* ─── MENÚ ──────────────────────────────────────────────────── */ + QMenuBar { background-color: #252830; - color: #e0e0e0; - border-bottom: 1px solid #3a3f4b; + color: #e8eaf0; + border-bottom: 1px solid #333841; + padding: 1px 0; +} +QMenuBar::item { + padding: 4px 10px; + background: transparent; +} +QMenuBar::item:selected { + background-color: #2d3139; + color: #ffffff; +} +QMenuBar::item:pressed { + background-color: #0d99ff; + color: #ffffff; } -QMenuBar::item:selected { background-color: #3a4050; } QMenu { - background-color: #2c313a; - color: #e0e0e0; - border: 1px solid #3a3f4b; -} -QMenu::item:selected { background-color: #1565c0; } -QMenu::separator { height: 1px; background: #3a3f4b; } - -QToolBar { background-color: #252830; - border-bottom: 1px solid #3a3f4b; - spacing: 4px; - padding: 2px; + color: #e8eaf0; + border: 1px solid #3a3f4b; + padding: 3px 0; } -QToolButton { - background: transparent; - border: 1px solid transparent; - border-radius: 3px; - padding: 3px; - color: #e0e0e0; +QMenu::item { + padding: 5px 28px 5px 20px; +} +QMenu::item:selected { + background-color: #0d99ff; + color: #ffffff; +} +QMenu::item:disabled { + color: #555b67; +} +QMenu::separator { + height: 1px; + background: #333841; + margin: 3px 8px; } -QToolButton:hover { background-color: #3a4050; border-color: #5a6070; } -QToolButton:pressed { background-color: #1565c0; } -QSplitter::handle { background-color: #3a3f4b; } -QSplitter::handle:horizontal { width: 3px; } -QSplitter::handle:vertical { height: 3px; } +/* ─── RIBBON TOOLBAR (contenedor) ───────────────────────────── */ -QTreeWidget, QTreeView, QListView, QTableView, QTableWidget { +QToolBar#ribbonToolbar { + background-color: #252830; + border: none; + border-bottom: 1px solid #333841; + padding: 0; + spacing: 0; + margin: 0; +} + +/* ─── RIBBON BAR ────────────────────────────────────────────── */ + +QWidget#ribbonBar { + background-color: #252830; +} + +/* Fila de pestañas */ +QWidget#ribbonTabRow { background-color: #1e2128; - alternate-background-color: #242830; - color: #e0e0e0; - border: 1px solid #3a3f4b; - gridline-color: #3a3f4b; - selection-background-color: #1565c0; - selection-color: #ffffff; + border-bottom: 1px solid #333841; } -QTreeWidget::item:hover, QTreeView::item:hover { background-color: #2a2f3a; } -QHeaderView::section { - background-color: #2c313a; - color: #b0b8c8; - border: 1px solid #3a3f4b; - padding: 4px 6px; + +/* Botones de pestañas */ +QToolButton#ribbonTabBtn { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 0 14px; + color: #8b919e; + font-size: 12px; + min-width: 80px; + height: 26px; +} +QToolButton#ribbonTabBtn:hover { + background-color: #2d3139; + color: #c8cad0; +} +QToolButton#ribbonTabBtn:checked { + background-color: #252830; + color: #0d99ff; + border-bottom: 2px solid #0d99ff; font-weight: bold; } -QTabWidget::pane { - border: 1px solid #3a3f4b; - background-color: #1e2128; -} -QTabBar::tab { +/* Stack de contenido del ribbon */ +QStackedWidget#ribbonContent { background-color: #252830; - color: #a0a8b8; - border: 1px solid #3a3f4b; - border-bottom: none; - padding: 5px 10px; - margin-right: 1px; -} -QTabBar::tab:selected { - background-color: #1e2128; - color: #ffffff; - border-bottom: 2px solid #1976d2; -} -QTabBar::tab:hover { background-color: #2c313a; color: #e0e0e0; } - -QScrollBar:vertical { - background-color: #1e2128; - width: 10px; -} -QScrollBar::handle:vertical { - background-color: #4a4f5b; - border-radius: 4px; - min-height: 20px; -} -QScrollBar::handle:vertical:hover { background-color: #5a6070; } -QScrollBar:horizontal { - background-color: #1e2128; - height: 10px; -} -QScrollBar::handle:horizontal { - background-color: #4a4f5b; - border-radius: 4px; - min-width: 20px; } -QPushButton { - background-color: #1976d2; - color: #ffffff; - border: none; - border-radius: 4px; - padding: 6px 14px; - font-weight: 500; -} -QPushButton:hover { background-color: #1e88e5; } -QPushButton:pressed { background-color: #1565c0; } -QPushButton:disabled { background-color: #3a3f4b; color: #6a7080; } - -QPushButton[secondary="true"] { +/* Grupo individual */ +QFrame#ribbonGroup { background-color: transparent; - color: #90caf9; - border: 1px solid #1976d2; + border: none; + margin: 2px 0; } -QPushButton[secondary="true"]:hover { background-color: #1a2a3a; } -QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { - background-color: #2c313a; - color: #e0e0e0; - border: 1px solid #4a4f5b; +/* Área de botones dentro del grupo */ +QWidget#ribbonBtnArea { + background-color: transparent; +} + +/* Etiqueta del nombre del grupo */ +QLabel#ribbonGroupTitle { + color: #8b919e; + font-size: 10px; + padding: 2px 0 3px 0; + background-color: transparent; +} + +/* Separador vertical entre grupos */ +QFrame#ribbonSep { + color: #2d3139; + margin: 6px 2px; +} + +/* Botones de la ribbon */ +QToolButton#ribbonButton { + background: transparent; + color: #e8eaf0; + border: 1px solid transparent; border-radius: 3px; - padding: 4px 6px; + padding: 4px 6px 2px 6px; + font-size: 11px; + min-width: 46px; + max-width: 72px; } -QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { - border-color: #1976d2; +QToolButton#ribbonButton:hover { + background-color: #2d3139; + border-color: #3a3f4b; +} +QToolButton#ribbonButton:pressed { + background-color: #0d99ff; + color: #ffffff; + border-color: #0d99ff; +} +QToolButton#ribbonButton:checked { + background-color: #1a4a70; + border-color: #0d99ff; +} +QToolButton#ribbonButton:disabled { + color: #555b67; } -QComboBox::drop-down { border: none; } -QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #a0a8b8; margin-right: 6px; } -QComboBox QAbstractItemView { background-color: #2c313a; color: #e0e0e0; selection-background-color: #1565c0; } +/* ─── 4 VIEWPORTS ───────────────────────────────────────────── */ -QLabel { color: #e0e0e0; } -QLabel[heading="true"] { font-size: 14px; font-weight: bold; color: #90caf9; } -QLabel[muted="true"] { color: #7a8090; } +QWidget#fourViewport { + background-color: #12151a; +} + +QFrame#viewportFrame { + background-color: #12151a; + border: 1px solid #1e2128; +} + +QWidget#viewportTitleBar { + background-color: #1a1e26; + border-bottom: 1px solid #2a2e38; +} + +QLabel#viewportTitle { + color: #6b7280; + font-size: 11px; + font-weight: bold; + letter-spacing: 0.5px; +} + +QWidget#viewportCanvas { + background-color: #0e1117; +} + +QLabel#viewportPlaceholder { + color: #1e2540; + font-size: 20px; + font-family: "Consolas", monospace; + font-weight: bold; + letter-spacing: 2px; +} + +/* Separadores arrastrables del viewport */ +QSplitter#viewportSplitter::handle { + background-color: #0d99ff; +} +QSplitter#viewportSplitter::handle:horizontal { + width: 3px; +} +QSplitter#viewportSplitter::handle:vertical { + height: 3px; +} +QSplitter#viewportSplitter::handle:hover { + background-color: #3db8ff; +} + +/* ─── DOCK WIDGETS ──────────────────────────────────────────── */ + +QDockWidget { + color: #e8eaf0; + font-weight: bold; + font-size: 12px; +} +QDockWidget::title { + background-color: #252830; + padding: 4px 8px; + border-bottom: 1px solid #333841; + text-align: left; +} +QDockWidget::close-button, +QDockWidget::float-button { + background: transparent; + border: none; + padding: 2px; +} +QDockWidget::close-button:hover, +QDockWidget::float-button:hover { + background-color: #3a3f4b; + border-radius: 2px; +} + +/* ─── PANEL DE CAPAS ────────────────────────────────────────── */ + +QWidget#layersPanel { + background-color: #1e2128; +} + +QWidget#layersPanelHeader { + background-color: #252830; + border-bottom: 1px solid #333841; +} + +QFrame#panelSep { + color: #333841; + max-height: 1px; +} + +QScrollArea#layersScrollArea { + background-color: #1e2128; + border: none; +} + +QWidget#layersContainer { + background-color: #1e2128; +} + +QWidget#layerRow { + background-color: transparent; + min-height: 26px; +} +QWidget#layerRow:hover { + background-color: #252830; +} + +QLabel#layerName { + color: #c8cad0; + font-size: 12px; +} + +/* Botón de visibilidad de capa */ +QToolButton#layerVisBtn { + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + color: #0d99ff; + font-size: 9px; + font-weight: bold; +} +QToolButton#layerVisBtn:hover { + background-color: #2d3139; + border-color: #3a3f4b; +} +QToolButton#layerVisBtn:checked { + background-color: #1a4a70; + border-color: #0d99ff; +} + +/* Botón de bloqueo de capa */ +QToolButton#layerLockBtn { + background: transparent; + border: 1px solid transparent; + border-radius: 2px; + color: #fbbf24; + font-size: 9px; +} +QToolButton#layerLockBtn:hover { + background-color: #2d3139; + border-color: #3a3f4b; +} +QToolButton#layerLockBtn:checked { + background-color: #3d2c00; + border-color: #fbbf24; +} + +/* ─── PROPIEDADES ───────────────────────────────────────────── */ + +QWidget#propContent { + background-color: #1e2128; +} +QLabel#propSection { + color: #8b919e; + font-size: 11px; + padding-bottom: 6px; + border-bottom: 1px solid #333841; + margin-bottom: 4px; +} +QLabel#propKey { + color: #8b919e; + font-size: 12px; +} +QLabel#propVal { + color: #e8eaf0; + font-size: 13px; +} + +/* ─── PANEL HIDROSTÁTICOS ───────────────────────────────────── */ + +QFrame#hydrostaticsPanel { + background-color: #14181f; + border-top: 1px solid #333841; +} +QLabel#hydroTitle { + color: #555b67; + font-size: 10px; + font-weight: bold; + padding: 0 10px 0 4px; + letter-spacing: 1px; +} +QFrame#hydroSep { + color: #333841; +} +QLabel#hydroKey { + color: #8b919e; + font-size: 11px; + padding: 0 2px; +} +QLabel#hydroVal { + color: #0d99ff; + font-family: "Consolas", monospace; + font-size: 12px; + min-width: 52px; +} +QLabel#hydroImoNone { + color: #555b67; + font-family: "Consolas", monospace; + font-size: 12px; +} +QLabel#hydroImoOk { + color: #34c759; + font-family: "Consolas", monospace; + font-size: 12px; + font-weight: bold; +} +QLabel#hydroImoFail { + color: #ff3b30; + font-family: "Consolas", monospace; + font-size: 12px; + font-weight: bold; +} + +/* ─── MÓDULOS PLACEHOLDER ───────────────────────────────────── */ + +QWidget#modulePlaceholder { + background-color: #1e2128; +} + +QLabel#placeholderTitle { + color: #2d3448; + font-size: 18px; + font-weight: bold; +} + +QLabel#placeholderDesc { + color: #3a4055; + font-size: 13px; +} + +QLabel#placeholderMsg { + color: #333841; + font-size: 12px; +} + +/* ─── BARRA DE ESTADO ───────────────────────────────────────── */ QStatusBar { - background-color: #1565c0; + background-color: #0d99ff; color: #ffffff; font-size: 12px; } QStatusBar::item { border: none; } -/* Panel Hidrostáticos — siempre visible abajo */ -#hydrostaticsPanel { - background-color: #151a22; - border-top: 2px solid #1976d2; +QLabel#statusVersion { + color: rgba(255,255,255,0.85); + font-size: 11px; + font-weight: bold; +} +QLabel#statusLabel { + color: rgba(255,255,255,0.70); + font-size: 11px; +} +QLabel#statusValue { + color: #ffffff; + font-size: 11px; + font-weight: bold; } -#hydrostaticsPanel QLabel { font-family: "Consolas", monospace; font-size: 12px; } -#hydrostaticsPanel QLabel[value="true"] { color: #80deea; font-weight: bold; } -#hydrostaticsPanel QLabel[label="true"] { color: #7a8090; } -#hydrostaticsPanel QLabel[imo_ok="true"] { color: #66bb6a; font-weight: bold; } -#hydrostaticsPanel QLabel[imo_fail="true"] { color: #ef5350; font-weight: bold; } -/* Panel Árbol de Proyecto */ -#projectTree { border-right: 1px solid #3a3f4b; } +/* ─── SCROLLBARS ────────────────────────────────────────────── */ -/* Panel Propiedades */ -#propertiesPanel { border-left: 1px solid #3a3f4b; } +QScrollBar:vertical { + background: #1e2128; + width: 10px; + border: none; +} +QScrollBar::handle:vertical { + background: #3a3f4b; + border-radius: 5px; + min-height: 24px; +} +QScrollBar::handle:vertical:hover { background: #555b67; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } + +QScrollBar:horizontal { + background: #1e2128; + height: 10px; + border: none; +} +QScrollBar::handle:horizontal { + background: #3a3f4b; + border-radius: 5px; + min-width: 24px; +} +QScrollBar::handle:horizontal:hover { background: #555b67; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } + +/* ─── BOTONES ───────────────────────────────────────────────── */ + +QPushButton { + background-color: #2d3139; + color: #e8eaf0; + border: 1px solid #3a3f4b; + border-radius: 3px; + padding: 5px 14px; + min-width: 72px; +} +QPushButton:hover { + background-color: #3a3f4b; + border-color: #555b67; +} +QPushButton:pressed { + background-color: #0d99ff; + color: #ffffff; + border-color: #0d99ff; +} +QPushButton:default { border-color: #0d99ff; } +QPushButton:disabled { color: #555b67; border-color: #2d3139; } + +/* ─── CAMPOS DE ENTRADA ─────────────────────────────────────── */ + +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { + background-color: #252830; + color: #e8eaf0; + border: 1px solid #3a3f4b; + border-radius: 3px; + padding: 4px 6px; + selection-background-color: #0d99ff; +} +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { + border-color: #0d99ff; +} +QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { + color: #555b67; + background-color: #1e2128; +} +QComboBox::drop-down { border: none; width: 20px; } +QComboBox QAbstractItemView { + background-color: #252830; + color: #e8eaf0; + border: 1px solid #3a3f4b; + selection-background-color: #0d99ff; +} + +/* ─── TABLAS Y LISTAS ───────────────────────────────────────── */ + +QHeaderView::section { + background-color: #252830; + color: #8b919e; + border: none; + border-right: 1px solid #333841; + border-bottom: 1px solid #333841; + padding: 4px 8px; + font-size: 12px; +} +QTableView, QListView { + background-color: #1e2128; + alternate-background-color: #212630; + color: #e8eaf0; + border: 1px solid #333841; + gridline-color: #2d3139; + selection-background-color: #0d99ff; + selection-color: #ffffff; +} +QTableView::item:hover, QListView::item:hover { + background-color: #2d3139; +} + +/* ─── ÁRBOL ─────────────────────────────────────────────────── */ + +QTreeWidget { + background-color: #1e2128; + alternate-background-color: #212630; + color: #e8eaf0; + border: none; + selection-background-color: #0d99ff; + selection-color: #ffffff; + font-size: 13px; +} +QTreeWidget::item { + padding: 3px 4px; + border: none; +} +QTreeWidget::item:hover { + background-color: #2d3139; +} +QTreeWidget::item:selected { + background-color: #0d99ff; + color: #ffffff; +} +QTreeWidget::branch { + background-color: #1e2128; +} + +/* ─── PESTAÑAS ──────────────────────────────────────────────── */ + +QTabWidget::pane { + border: none; + border-top: 1px solid #333841; + background-color: #1e2128; +} +QTabBar { + background-color: #252830; +} +QTabBar::tab { + background-color: #252830; + color: #8b919e; + border: none; + border-right: 1px solid #333841; + padding: 6px 14px; + min-width: 70px; + font-size: 12px; +} +QTabBar::tab:selected { + background-color: #1e2128; + color: #e8eaf0; + border-top: 2px solid #0d99ff; +} +QTabBar::tab:hover:!selected { + background-color: #2d3139; + color: #e8eaf0; +} + +/* ─── SPLITTER GENERAL ──────────────────────────────────────── */ + +QSplitter::handle { background-color: #333841; } +QSplitter::handle:horizontal { width: 1px; } +QSplitter::handle:vertical { height: 1px; } + +/* ─── MENSAJES / TOOLTIP ────────────────────────────────────── */ + +QMessageBox { background-color: #252830; } +QMessageBox QLabel { color: #e8eaf0; } + +QToolTip { + background-color: #252830; + color: #e8eaf0; + border: 1px solid #3a3f4b; + padding: 4px 6px; + font-size: 12px; +} + +/* ─── WIDGET DE INFO (ribbonInfoWidget legado) ──────────────── */ + +QWidget#ribbonInfoWidget { + background-color: transparent; + border-left: 1px solid #333841; + padding-left: 4px; +} +QLabel#tbLabel { + color: #8b919e; + font-size: 11px; +} +QLabel#tbValue { + color: #0d99ff; + font-weight: bold; + font-size: 11px; +}