diff --git a/pyproject.toml b/pyproject.toml index 40da37e..11a514c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,19 +17,25 @@ dependencies = [ ] [project.optional-dependencies] +studio = [ + "PySide6>=6.6,<7.0", +] dev = [ "pytest>=7.4", "pytest-cov>=4.1", "pytest-asyncio>=0.23", + "pytest-qt>=4.4", "ruff>=0.4.0", "mypy>=1.10", "types-PyYAML", "types-python-dateutil", + "PySide6>=6.6,<7.0", ] [project.scripts] vms-validate-library = "vmssailor.tools.validate_library:main" vms-generate-test-project = "vmssailor.tools.generate_test_project:main" +vms-studio = "vmssailor.studio.app:run_studio" [build-system] requires = ["hatchling"] @@ -63,6 +69,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**" = ["B011"] "tools/**" = ["E402"] +"vmssailor/studio/**" = ["RUF001", "E402"] # caracteres tipograficos intencionales en UI ES; imports diferidos de Signal/Property en QWizard [tool.ruff.format] quote-style = "double" diff --git a/studio_main.py b/studio_main.py index 29098ed..2942521 100644 --- a/studio_main.py +++ b/studio_main.py @@ -1,6 +1,12 @@ -"""Entry point del Studio (stub Sprint 0). +"""Entry point del Studio. -Sprint 1 lo reemplaza con `vmssailor.studio.app:main`. +Lanza la aplicación PySide6 del Studio. Para correr: + + uv run python studio_main.py + +o desde el entry-point declarado en pyproject.toml: + + uv run vms-studio """ from __future__ import annotations @@ -9,10 +15,9 @@ import sys def main() -> int: - print("VMS-Sailor Studio — Sprint 1 trae la UI PySide6.") - print("En Sprint 0 solo existe el modelo de datos core.") - print("Ver `python tools/generate_test_project.py` para verificar el roundtrip.") - return 0 + from vmssailor.studio.app import run_studio + + return run_studio(sys.argv) if __name__ == "__main__": diff --git a/tests/studio/__init__.py b/tests/studio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/studio/conftest.py b/tests/studio/conftest.py new file mode 100644 index 0000000..ccdc713 --- /dev/null +++ b/tests/studio/conftest.py @@ -0,0 +1,12 @@ +"""Fixtures para tests de UI Sprint 1+. + +`pytest-qt` provee la fixture `qtbot` que arranca un QApplication en modo +offscreen automáticamente. No hace falta hacer nada extra acá. +""" + +from __future__ import annotations + +import os + +# Forzar plataforma offscreen para CI / corridas sin display +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") diff --git a/tests/studio/test_main_window.py b/tests/studio/test_main_window.py new file mode 100644 index 0000000..9d146ce --- /dev/null +++ b/tests/studio/test_main_window.py @@ -0,0 +1,127 @@ +"""Smoke tests del Studio Sprint 1.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6") + + +def test_app_constructs(qtbot): + from vmssailor.studio.app import StudioApp + + # qtbot ya construyó un QCoreApplication. Verificamos que apply_theme no rompe. + # No instanciamos StudioApp porque qtbot ya tiene QApplication activo. + _ = StudioApp # import OK + assert True + + +def test_main_window_builds(qtbot): + from vmssailor.studio.main_window import MainWindow + + w = MainWindow() + qtbot.addWidget(w) + assert w.windowTitle().startswith("VMS-Sailor") + assert w.current_project() is None + + +def test_main_window_set_project(qtbot, sample_project): + from vmssailor.studio.main_window import MainWindow + + w = MainWindow() + qtbot.addWidget(w) + w.set_project(sample_project, path=None) + assert w.current_project() is sample_project + # Stats label refleja proyecto + assert "1 sistemas" in w._stats_label.text() + + +def test_wizard_builds_with_8_pages(qtbot): + from vmssailor.studio.wizard.wizard import VesselWizard + + wiz = VesselWizard() + qtbot.addWidget(wiz) + assert len(wiz.pageIds()) == 8 + + +def test_wizard_step_01_default_subtypes(qtbot): + from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType + + step = Step01VesselType() + qtbot.addWidget(step) + # default type = yacht_motor → subtypes incluyen planing/semi/displacement + items = [step._subtype_combo.itemData(i) for i in range(step._subtype_combo.count())] + assert "planing" in items + assert "displacement" in items + + +def test_wizard_step_04_systems_select_defaults(qtbot): + from vmssailor.studio.wizard.step_04_systems import Step04Systems + + step = Step04Systems() + qtbot.addWidget(step) + # Simular VesselType de yacht_motor + step.setField = lambda *a, **k: None # noop + # Forzamos pre-selection llamando directamente + # (no podemos invocar field() sin un QWizard padre) + enabled = step.get_enabled_systems() + # Inicialmente todos desmarcados (hasta initializePage) + assert isinstance(enabled, list) + + +def test_vessel_canvas_empty_state(qtbot): + from vmssailor.studio.widgets.vessel_canvas import VesselCanvas + + c = VesselCanvas() + qtbot.addWidget(c) + # Empty scene tiene un placeholder text + grid lines + assert c._scene.items() # algo se renderizó + + +def test_vessel_canvas_renders_project(qtbot, sample_project): + from vmssailor.studio.widgets.vessel_canvas import VesselCanvas + + c = VesselCanvas() + qtbot.addWidget(c) + c.set_project(sample_project) + # Hay items en la escena (silueta + grid + bulkheads + equipos) + assert len(c._scene.items()) > 10 + + +def test_system_sidebar_with_project(qtbot, sample_project): + from vmssailor.studio.widgets.system_sidebar import SystemSidebar + + sb = SystemSidebar() + qtbot.addWidget(sb) + sb.set_project(sample_project) + # Debe haber al menos 1 sistema habilitado en la lista + assert sb._enabled_list.count() >= 1 + + +def test_ship_to_scene_mapping(): + """Coordenadas naval → escena: y_cl > 0 (estribor) cae en y de pantalla negativo.""" + from vmssailor.core.coords import ShipCoord + from vmssailor.studio.widgets.vessel_canvas import ship_to_scene + + starboard = ShipCoord(x_pp=10.0, y_cl=2.0, z_bl=1.0) + scene_pt = ship_to_scene(starboard, px_per_m=10) + assert scene_pt.x() == 100 # 10 m * 10 px/m + assert scene_pt.y() == -20 # estribor → y de pantalla negativo (arriba) + + +def test_studio_main_module_importable(): + """`python studio_main.py` debe ser importable sin lanzar la UI.""" + import importlib + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + sys.path.insert(0, str(root)) + try: + spec = importlib.util.spec_from_file_location("_studio_main", root / "studio_main.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + assert callable(mod.main) + finally: + if str(root) in sys.path: + sys.path.remove(str(root)) diff --git a/uv.lock b/uv.lock index 9810c00..64bd372 100644 --- a/uv.lock +++ b/uv.lock @@ -223,6 +223,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyside6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-addons" }, + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, +] + +[[package]] +name = "pyside6-addons" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyside6-essentials" }, + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, +] + +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -266,6 +314,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-qt" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -320,6 +382,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] +[[package]] +name = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -399,25 +473,33 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "mypy" }, + { name = "pyside6" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-qt" }, { name = "ruff" }, { name = "types-python-dateutil" }, { name = "types-pyyaml" }, ] +studio = [ + { name = "pyside6" }, +] [package.metadata] requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "pydantic", specifier = ">=2.5,<3.0" }, + { name = "pyside6", marker = "extra == 'dev'", specifier = ">=6.6,<7.0" }, + { name = "pyside6", marker = "extra == 'studio'", specifier = ">=6.6,<7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, + { name = "pytest-qt", marker = "extra == 'dev'", specifier = ">=4.4" }, { name = "python-dateutil", specifier = ">=2.8" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "types-python-dateutil", marker = "extra == 'dev'" }, { name = "types-pyyaml", marker = "extra == 'dev'" }, ] -provides-extras = ["dev"] +provides-extras = ["studio", "dev"] diff --git a/vmssailor/studio/__init__.py b/vmssailor/studio/__init__.py index a62a183..95144ee 100644 --- a/vmssailor/studio/__init__.py +++ b/vmssailor/studio/__init__.py @@ -1,5 +1,10 @@ -"""vmssailor.studio — Aplicación de escritorio del integrador (Sprint 1+). +"""vmssailor.studio — Aplicación de escritorio del integrador. -En Sprint 0 está vacío. Sprint 1 trae la shell PySide6, ventana principal y -wizard pasos 1-4. Ver `VMS_Sailor_v2_Parte_02_Studio.md`. +Sprint 1: shell + wizard pasos 1-4 + canvas con silueta + sidebar dinámico. + +Entry point: `studio_main.py` en la raíz del repo, o `python -m vmssailor.studio`. """ + +from vmssailor.studio.app import StudioApp, run_studio + +__all__ = ["StudioApp", "run_studio"] diff --git a/vmssailor/studio/app.py b/vmssailor/studio/app.py new file mode 100644 index 0000000..63f8a8a --- /dev/null +++ b/vmssailor/studio/app.py @@ -0,0 +1,60 @@ +"""StudioApp — bootstrap de la aplicación PySide6.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication + +from vmssailor.shared.logging_setup import setup_logging +from vmssailor.studio.theme import apply_theme +from vmssailor.version import __version__ + +BRAND_ROOT = Path(__file__).resolve().parents[2] / "docs" / "brand" + + +class StudioApp(QApplication): + """Application class del Studio. Aplica tema y configuración global.""" + + def __init__(self, argv: list[str] | None = None) -> None: + super().__init__(argv or sys.argv) + self.setOrganizationName("Aerom") + self.setApplicationName("VMS-Sailor Studio") + self.setApplicationVersion(__version__) + self.setApplicationDisplayName("VMS-Sailor Studio") + + # Hi-DPI scaling se maneja automáticamente en Qt6. + # Solo seteamos política de redondeo amigable. + self.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + # Logo del app + icon_svg = BRAND_ROOT / "favicon.svg" + if icon_svg.exists(): + self.setWindowIcon(QIcon(str(icon_svg))) + + apply_theme(self) + + +def run_studio(argv: list[str] | None = None) -> int: + """Lanza la aplicación Studio y bloquea hasta que cierre. + + Devuelve el exit code de Qt. + """ + setup_logging() + + app = StudioApp(argv) + + # Import perezoso para evitar costos cuando solo se chequea --help. + from vmssailor.studio.main_window import MainWindow + + window = MainWindow() + window.resize(QSize(1440, 900)) + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(run_studio()) diff --git a/vmssailor/studio/main_window.py b/vmssailor/studio/main_window.py new file mode 100644 index 0000000..4034c38 --- /dev/null +++ b/vmssailor/studio/main_window.py @@ -0,0 +1,368 @@ +"""Ventana principal del Studio. + +Layout: topbar / sidebar / canvas / inspector / statusbar (ver +`docs/mockups/studio_main.html` para la referencia visual). +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QAction, QIcon, QKeySequence +from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QMainWindow, + QMessageBox, + QPushButton, + QSplitter, + QStatusBar, + QToolBar, + QVBoxLayout, + QWidget, +) + +from vmssailor.core.persistence import load_project, save_project +from vmssailor.core.project import Project +from vmssailor.studio.theme import ( + C_FOAM, + C_FOG, + mono_font, + ui_font, +) +from vmssailor.studio.widgets.system_sidebar import SystemSidebar +from vmssailor.studio.widgets.vessel_canvas import VesselCanvas +from vmssailor.studio.wizard.wizard import VesselWizard + +logger = logging.getLogger(__name__) + + +BRAND_ROOT = Path(__file__).resolve().parents[2] / "docs" / "brand" + + +class MainWindow(QMainWindow): + """Ventana principal del Studio.""" + + projectChanged = Signal(Project) + + def __init__(self) -> None: + super().__init__() + + self.setWindowTitle("VMS-Sailor Studio") + self.setMinimumSize(1200, 720) + + self._project: Project | None = None + self._project_path: Path | None = None + + self._build_topbar() + self._build_central() + self._build_statusbar() + self._build_menus() + self._build_toolbar() + self._connect_signals() + self._update_window_title() + + # Clock tick + self._clock_timer = QTimer(self) + self._clock_timer.timeout.connect(self._update_clock) + self._clock_timer.start(1000) + self._update_clock() + + # ----- UI build ----------------------------------------------------- + + def _build_topbar(self) -> None: + bar = QWidget() + bar.setObjectName("topbar") + bar.setFixedHeight(56) + layout = QHBoxLayout(bar) + layout.setContentsMargins(20, 0, 20, 0) + layout.setSpacing(16) + + logo_path = BRAND_ROOT / "logo.svg" + if logo_path.exists(): + logo_label = QLabel() + pix_label = QIcon(str(logo_path)).pixmap(120, 32) + logo_label.setPixmap(pix_label) + layout.addWidget(logo_label) + + # Breadcrumb area + self._breadcrumb_label = QLabel("Sin proyecto abierto") + self._breadcrumb_label.setObjectName("title") + self._breadcrumb_label.setFont(ui_font(11)) + layout.addWidget(self._breadcrumb_label) + + layout.addStretch(1) + + self._dirty_badge = QLabel("● Sin cambios sin guardar") + self._dirty_badge.setObjectName("caption") + self._dirty_badge.setStyleSheet(f"color: {C_FOG};") + layout.addWidget(self._dirty_badge) + + # Topbar action buttons + self._btn_validate = QPushButton("Validar") + self._btn_compile = QPushButton("Compilar .vmspack") + self._btn_save_top = QPushButton("Guardar") + self._btn_save_top.setObjectName("primary") + for b in (self._btn_validate, self._btn_compile, self._btn_save_top): + layout.addWidget(b) + + self._topbar = bar + + def _build_central(self) -> None: + # Splitter horizontal: sidebar | canvas + self._splitter = QSplitter(Qt.Horizontal) + self._splitter.setHandleWidth(1) + self._splitter.setChildrenCollapsible(False) + + self._sidebar = SystemSidebar() + self._sidebar.setObjectName("sidebar") + self._sidebar.setMinimumWidth(260) + self._sidebar.setMaximumWidth(380) + + self._canvas = VesselCanvas() + + self._splitter.addWidget(self._sidebar) + self._splitter.addWidget(self._canvas) + self._splitter.setSizes([280, 1160]) + + # Compose central widget: topbar (top) + splitter (rest) + wrapper = QWidget() + outer = QVBoxLayout(wrapper) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + outer.addWidget(self._topbar) + outer.addWidget(self._splitter, 1) + self.setCentralWidget(wrapper) + + def _build_statusbar(self) -> None: + sb = QStatusBar(self) + sb.setSizeGripEnabled(False) + sb.setFont(mono_font(9)) + + self._stats_label = QLabel("0 sistemas · 0 equipos · 0 tags · 0 tarjetas") + sb.addWidget(self._stats_label, 1) + + self._clock_label = QLabel("--:--:--") + self._clock_label.setFont(mono_font(9)) + sb.addPermanentWidget(self._clock_label) + + from vmssailor.version import __version__ as v + + version_label = QLabel(f"Studio {v} · Sprint 1") + version_label.setFont(mono_font(9)) + version_label.setStyleSheet(f"color: {C_FOG};") + sb.addPermanentWidget(version_label) + + self.setStatusBar(sb) + + def _build_menus(self) -> None: + mb = self.menuBar() + + # ------- Archivo -------- + m_file = mb.addMenu("&Proyecto") + + self._act_new = QAction("&Nuevo desde wizard…", self) + self._act_new.setShortcut(QKeySequence.New) + m_file.addAction(self._act_new) + + self._act_open = QAction("&Abrir .vmsproj…", self) + self._act_open.setShortcut(QKeySequence.Open) + m_file.addAction(self._act_open) + + self._act_save = QAction("&Guardar", self) + self._act_save.setShortcut(QKeySequence.Save) + self._act_save.setEnabled(False) + m_file.addAction(self._act_save) + + self._act_save_as = QAction("Guardar &como…", self) + self._act_save_as.setShortcut("Ctrl+Shift+S") + self._act_save_as.setEnabled(False) + m_file.addAction(self._act_save_as) + + m_file.addSeparator() + + self._act_exit = QAction("&Salir", self) + self._act_exit.setShortcut("Ctrl+Q") + m_file.addAction(self._act_exit) + + # ------- Edición -------- + m_edit = mb.addMenu("&Edición") + m_edit.addAction(QAction("(stubs Sprint 2)", self, enabled=False)) + + # ------- Vista -------- + m_view = mb.addMenu("&Vista") + m_view.addAction(QAction("(stubs Sprint 2)", self, enabled=False)) + + # ------- Ayuda -------- + m_help = mb.addMenu("A&yuda") + self._act_about = QAction("&Acerca de…", self) + m_help.addAction(self._act_about) + + def _build_toolbar(self) -> None: + tb = QToolBar("Principal", self) + tb.setMovable(False) + tb.setIconSize(self.iconSize()) + self.addToolBar(Qt.LeftToolBarArea, tb) + tb.hide() # placeholder Sprint 2 + + def _connect_signals(self) -> None: + self._act_new.triggered.connect(self.on_new_wizard) + self._act_open.triggered.connect(self.on_open) + self._act_save.triggered.connect(self.on_save) + self._act_save_as.triggered.connect(self.on_save_as) + self._act_exit.triggered.connect(self.close) + self._act_about.triggered.connect(self.on_about) + + self._btn_save_top.clicked.connect(self.on_save) + self._btn_validate.clicked.connect(self.on_validate) + self._btn_compile.clicked.connect(self.on_compile) + + self.projectChanged.connect(self._sidebar.set_project) + self.projectChanged.connect(self._canvas.set_project) + + # ----- Slots -------------------------------------------------------- + + def on_new_wizard(self) -> None: + wiz = VesselWizard(self) + if wiz.exec(): + project = wiz.build_project() + self.set_project(project, path=None) + self.statusBar().showMessage("Proyecto creado desde wizard.", 4000) + + def on_open(self) -> None: + path_str, _ = QFileDialog.getOpenFileName( + self, + "Abrir proyecto VMS-Sailor", + "", + "VMS-Sailor projects (*.vmsproj);;Todos (*)", + ) + if not path_str: + return + path = Path(path_str) + try: + project = load_project(path) + except Exception as exc: + QMessageBox.critical(self, "Error al abrir", f"No se pudo abrir {path}\n\n{exc}") + return + self.set_project(project, path=path) + self.statusBar().showMessage(f"Abierto: {path.name}", 4000) + + def on_save(self) -> None: + if not self._project: + return + if not self._project_path: + self.on_save_as() + return + try: + save_project(self._project, self._project_path) + except Exception as exc: + QMessageBox.critical(self, "Error al guardar", f"{exc}") + return + self.statusBar().showMessage(f"Guardado: {self._project_path.name}", 4000) + self._dirty_badge.setText("● Sin cambios sin guardar") + + def on_save_as(self) -> None: + if not self._project: + return + default = f"{self._project.id}.vmsproj" + path_str, _ = QFileDialog.getSaveFileName( + self, + "Guardar proyecto VMS-Sailor", + default, + "VMS-Sailor projects (*.vmsproj)", + ) + if not path_str: + return + path = Path(path_str) + if path.suffix.lower() != ".vmsproj": + path = path.with_suffix(".vmsproj") + self._project_path = path + self.on_save() + self._update_window_title() + + def on_validate(self) -> None: + if not self._project: + QMessageBox.information(self, "Validar", "Abre o crea un proyecto primero.") + return + from vmssailor.core.validation import validate_project + + report = validate_project(self._project) + msg = report.format() + ok = report.ok() + title = "Validación · OK" if ok else "Validación · ERRORES" + if ok and not report.warnings and not report.infos: + QMessageBox.information(self, title, "Sin issues. Todo en regla.") + else: + QMessageBox.information(self, title, msg) + + def on_compile(self) -> None: + QMessageBox.information( + self, + "Compilar .vmspack", + "El compilador .vmspack llega en Sprint 7. En Sprint 1 sólo guardamos el .vmsproj.", + ) + + def on_about(self) -> None: + from vmssailor.version import __version__ as v + + QMessageBox.about( + self, + "VMS-Sailor Studio", + f"""

VMS-Sailor Studio

+

Versión {v} · Sprint 1

+

Herramienta de ingeniería para configurar el VMS-Sailor de cada buque.

+

Propiedad intelectual de Álvaro. Todos los derechos reservados.

""", + ) + + # ----- Public ops --------------------------------------------------- + + def set_project(self, project: Project | None, *, path: Path | None) -> None: + self._project = project + self._project_path = path + has_project = project is not None + self._act_save.setEnabled(has_project) + self._act_save_as.setEnabled(has_project) + self._btn_save_top.setEnabled(has_project) + self._btn_validate.setEnabled(has_project) + self._btn_compile.setEnabled(has_project) + self._update_window_title() + self._update_stats() + if project is not None: + self.projectChanged.emit(project) + + # ----- Helpers ------------------------------------------------------ + + def _update_window_title(self) -> None: + if not self._project: + self.setWindowTitle("VMS-Sailor Studio — Sin proyecto") + self._breadcrumb_label.setText( + "VMS-Sailor Studio · sin proyecto abierto · usa Proyecto › Nuevo desde wizard" + ) + return + crumbs = f"Proyectos / {self._project.id} / Topología" + self._breadcrumb_label.setText(crumbs) + path_str = str(self._project_path) if self._project_path else "(sin guardar)" + self.setWindowTitle(f"VMS-Sailor Studio — {self._project.name} ({path_str})") + + def _update_stats(self) -> None: + if not self._project: + self._stats_label.setText("0 sistemas · 0 equipos · 0 tags · 0 tarjetas") + return + s = self._project.stats() + self._stats_label.setText( + f"{s['systems']} sistemas · {s['equipment']} equipos · {s['tags']} tags · " + f"{s['cards']} tarjetas · {s['permissive_rules']} rules" + ) + + def _update_clock(self) -> None: + now = datetime.now() + self._clock_label.setText(now.strftime("%H:%M:%S · %Y-%m-%d")) + + # ----- Test helpers ------------------------------------------------- + + def current_project(self) -> Project | None: + return self._project diff --git a/vmssailor/studio/theme.py b/vmssailor/studio/theme.py new file mode 100644 index 0000000..2a747de --- /dev/null +++ b/vmssailor/studio/theme.py @@ -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) diff --git a/vmssailor/studio/widgets/__init__.py b/vmssailor/studio/widgets/__init__.py new file mode 100644 index 0000000..83833a4 --- /dev/null +++ b/vmssailor/studio/widgets/__init__.py @@ -0,0 +1 @@ +"""Widgets reutilizables del Studio.""" diff --git a/vmssailor/studio/widgets/system_sidebar.py b/vmssailor/studio/widgets/system_sidebar.py new file mode 100644 index 0000000..0193e14 --- /dev/null +++ b/vmssailor/studio/widgets/system_sidebar.py @@ -0,0 +1,165 @@ +"""Sidebar dinámico de sistemas del proyecto. + +Lee el catálogo maestro de sistemas y muestra: +- Sistemas habilitados del Project (en la parte superior, con contador de equipos) +- Sistemas NO habilitados pero disponibles (al pie, en gris) + +En Sprint 1 es sólo visualización. En Sprint 2 se podrá toggle desde aquí. +""" + +from __future__ import annotations + +from collections import Counter + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QFrame, + QLabel, + QListWidget, + QListWidgetItem, + QVBoxLayout, + QWidget, +) + +from vmssailor.core.enums import SystemId +from vmssailor.core.project import Project +from vmssailor.library import load_systems_catalog +from vmssailor.studio.theme import C_FOG, ui_font + + +class SystemSidebar(QWidget): + """Lista lateral de sistemas. Emite `systemActivated(SystemId)` al doble-click.""" + + systemActivated = Signal(str) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._project: Project | None = None + self._catalog = self._load_catalog_safe() + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Header section: wizard / overview + self._wizard_label = QLabel("WIZARD") + self._wizard_label.setObjectName("overline") + self._wizard_label.setFont(ui_font(9)) + self._wizard_label.setStyleSheet( + f"color: {C_FOG}; padding: 16px 16px 6px 16px; letter-spacing: 2px;" + ) + layout.addWidget(self._wizard_label) + + self._wizard_steps = QListWidget() + self._wizard_steps.setFrameShape(QFrame.Shape.NoFrame) + for step in self._WIZARD_STEPS: + item = QListWidgetItem(step) + item.setFlags(item.flags() & ~Qt.ItemIsSelectable) + self._wizard_steps.addItem(item) + self._wizard_steps.setFixedHeight(self._wizard_steps.sizeHintForRow(0) * 8 + 10) + layout.addWidget(self._wizard_steps) + + # Systems section + self._systems_header = QLabel("SISTEMAS HABILITADOS") + self._systems_header.setObjectName("overline") + self._systems_header.setFont(ui_font(9)) + self._systems_header.setStyleSheet( + f"color: {C_FOG}; padding: 16px 16px 6px 16px; letter-spacing: 2px;" + ) + layout.addWidget(self._systems_header) + + self._enabled_list = QListWidget() + self._enabled_list.setFrameShape(QFrame.Shape.NoFrame) + self._enabled_list.itemDoubleClicked.connect(self._on_double_click_enabled) + layout.addWidget(self._enabled_list, 1) + + self._available_header = QLabel("DISPONIBLES (NO HABILITADOS)") + self._available_header.setObjectName("overline") + self._available_header.setFont(ui_font(9)) + self._available_header.setStyleSheet( + f"color: {C_FOG}; padding: 16px 16px 6px 16px; letter-spacing: 2px;" + ) + layout.addWidget(self._available_header) + + self._available_list = QListWidget() + self._available_list.setFrameShape(QFrame.Shape.NoFrame) + self._available_list.setMaximumHeight(140) + layout.addWidget(self._available_list) + + self.refresh() + + # ----- Const ----------------------------------------------------- + + _WIZARD_STEPS = ( + "1 · Tipo de buque", + "2 · Plantilla", + "3 · Dimensiones", + "4 · Sistemas", + "5 · Equipos sugeridos", + "6 · Refinamiento", + "7 · Topología I/O", + "8 · Confirmación", + ) + + # ----- Public ---------------------------------------------------- + + def set_project(self, project: Project | None) -> None: + self._project = project + self.refresh() + + def refresh(self) -> None: + self._enabled_list.clear() + self._available_list.clear() + + if self._project is None: + self._systems_header.setText("SISTEMAS HABILITADOS · sin proyecto") + self._enabled_list.addItem("(crea o abre un proyecto)") + return + + enabled = set(self._project.systems_enabled) + equipment_count: Counter[str] = Counter() + for eq in self._project.equipment: + equipment_count[eq.system_id.value] += 1 + + self._systems_header.setText( + f"SISTEMAS HABILITADOS · {len(enabled)}" + ) + + for sys_id_str in sorted(s.value for s in enabled): + name = self._system_display_name(sys_id_str) + count = equipment_count.get(sys_id_str, 0) + label = f"{name} · {count} eq" if count else f"{name} · —" + item = QListWidgetItem(label) + item.setData(Qt.UserRole, sys_id_str) + self._enabled_list.addItem(item) + + all_known = {s.value for s in SystemId} + disabled = sorted(all_known - {s.value for s in enabled}) + self._available_header.setText( + f"DISPONIBLES (NO HABILITADOS) · {len(disabled)}" + ) + for sys_id_str in disabled[:40]: + item = QListWidgetItem(self._system_display_name(sys_id_str)) + item.setForeground(Qt.GlobalColor.darkGray) + item.setData(Qt.UserRole, sys_id_str) + self._available_list.addItem(item) + + # ----- Internals ------------------------------------------------- + + def _load_catalog_safe(self) -> dict: + try: + return load_systems_catalog() + except Exception: + return {"categories": []} + + def _system_display_name(self, sys_id_value: str) -> str: + for cat in self._catalog.get("categories", []): + for s in cat.get("systems", []): + if s.get("id") == sys_id_value: + return s.get("name", sys_id_value) + return sys_id_value.replace("_", " ").title() + + def _on_double_click_enabled(self, item: QListWidgetItem) -> None: + sys_id = item.data(Qt.UserRole) + if sys_id: + self.systemActivated.emit(sys_id) diff --git a/vmssailor/studio/widgets/vessel_canvas.py b/vmssailor/studio/widgets/vessel_canvas.py new file mode 100644 index 0000000..61f2fd0 --- /dev/null +++ b/vmssailor/studio/widgets/vessel_canvas.py @@ -0,0 +1,345 @@ +"""VesselCanvas — QGraphicsView con grilla naval y silueta del buque. + +Sistema de coordenadas: +- ShipCoord (x_pp, y_cl, z_bl) en metros +- Transformación a pixels: 1 m → 20 px (configurable vía zoom) +- Origen Pp (popa) en la izquierda, proa a la derecha + +Sprint 1: muestra eslora con grilla, marcadores de mamparos y equipos. +""" + +from __future__ import annotations + +from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtGui import ( + QBrush, + QColor, + QLinearGradient, + QPainter, + QPen, + QPolygonF, +) +from PySide6.QtWidgets import ( + QFrame, + QGraphicsScene, + QGraphicsView, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from vmssailor.core.coords import ShipCoord +from vmssailor.core.project import Project +from vmssailor.studio.theme import ( + C_ABYSS, + C_CYAN, + C_CYAN_DEEP, + C_FOAM, + C_FOG, + C_MIDNIGHT, + C_SAND, + C_STEEL, + C_WARN, + mono_font, + ui_font, +) + +# Mapeo metros -> pixels por defecto +PX_PER_M_DEFAULT = 20.0 + + +def ship_to_scene(coord: ShipCoord, px_per_m: float = PX_PER_M_DEFAULT) -> QPointF: + """Transformación de coordenadas navales a coordenadas de la escena. + + En la escena: X crece a la derecha (= x_pp positivo hacia proa), + Y crece HACIA ABAJO en Qt — por eso invertimos y_cl. + + El centro vertical de la escena (y=0) corresponde a la línea de crujía. + """ + x = coord.x_pp * px_per_m + y = -coord.y_cl * px_per_m # estribor abajo, babor arriba en pantalla + return QPointF(x, y) + + +class VesselCanvas(QWidget): + """Canvas central del Studio.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._project: Project | None = None + self._px_per_m = PX_PER_M_DEFAULT + + # Hero header + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + header = QWidget() + header.setFixedHeight(56) + header.setStyleSheet(f"background: {C_MIDNIGHT}; border-bottom: 1px solid {C_STEEL};") + h_lay = QHBoxLayout(header) + h_lay.setContentsMargins(24, 0, 24, 0) + self._title = QLabel("Topología del buque") + self._title.setObjectName("title") + self._title.setFont(ui_font(13)) + self._title.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;") + h_lay.addWidget(self._title) + h_lay.addStretch(1) + self._zoom_label = QLabel("Zoom: 100%") + self._zoom_label.setFont(mono_font(9)) + self._zoom_label.setStyleSheet(f"color: {C_FOG};") + h_lay.addWidget(self._zoom_label) + layout.addWidget(header) + + # Scene + view + self._scene = QGraphicsScene(self) + self._view = _VesselGraphicsView(self._scene, self) + self._view.setFrameShape(QFrame.NoFrame) + self._view.setRenderHint(QPainter.Antialiasing) + self._view.setRenderHint(QPainter.SmoothPixmapTransform) + self._view.setBackgroundBrush(self._background_brush()) + self._view.setDragMode(QGraphicsView.ScrollHandDrag) + self._view.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + self._view.zoomChanged.connect(self._on_zoom_changed) + layout.addWidget(self._view, 1) + + self._render_empty_state() + + # ----- Background --------------------------------------------------- + + def _background_brush(self) -> QBrush: + grad = QLinearGradient(0, 0, 0, 1) + grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode) + grad.setColorAt(0.0, QColor(C_ABYSS)) + grad.setColorAt(1.0, QColor(C_MIDNIGHT)) + return QBrush(grad) + + # ----- Public ------------------------------------------------------- + + def set_project(self, project: Project | None) -> None: + self._project = project + if project is None: + self._render_empty_state() + else: + self._render_project() + + def fit_in_view(self) -> None: + if not self._scene.items(): + return + self._view.fitInView(self._scene.itemsBoundingRect().adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) + + # ----- Rendering ---------------------------------------------------- + + def _render_empty_state(self) -> None: + self._scene.clear() + self._scene.setSceneRect(QRectF(-200, -200, 1200, 600)) + # Grid + self._draw_grid(self._scene.sceneRect(), grid_m=1.0) + # Center label + msg = self._scene.addText( + "Abre un proyecto existente (.vmsproj) o crea uno con\n" + "Proyecto › Nuevo desde wizard…", + ui_font(11), + ) + msg.setDefaultTextColor(QColor(C_FOG)) + rect = msg.boundingRect() + msg.setPos(-rect.width() / 2 + 400, -rect.height() / 2 + 100) + self._title.setText("Topología del buque · sin proyecto") + + def _render_project(self) -> None: + assert self._project is not None + self._scene.clear() + v = self._project.vessel + + # Compute scene rect + margin_m = 5.0 + rect_m = QRectF( + -margin_m * self._px_per_m, + -(v.beam_max_m / 2 + margin_m) * self._px_per_m, + (v.length_overall_m + 2 * margin_m) * self._px_per_m, + (v.beam_max_m + 2 * margin_m) * self._px_per_m, + ) + self._scene.setSceneRect(rect_m) + + # Grid + self._draw_grid(rect_m, grid_m=1.0) + + # Silhouette (plan view, simplified): a hull-shaped polygon + self._draw_vessel_plan(v) + + # Bulkheads + for b in v.bulkheads: + x_px = b.x_pp * self._px_per_m + self._draw_bulkhead(x_px, v.beam_max_m, b.name) + + # Equipment markers + for eq in self._project.equipment: + self._draw_equipment(eq) + + # Axes + self._draw_axes(v.length_overall_m, v.beam_max_m) + + self._title.setText( + f"Topología del buque · {v.name} · " + f"{v.length_overall_m:.1f}×{v.beam_max_m:.1f}×{v.draft_m:.1f} m" + ) + + # Fit + self.fit_in_view() + + def _draw_grid(self, rect: QRectF, grid_m: float = 1.0) -> None: + step = grid_m * self._px_per_m + pen = QPen(QColor(C_STEEL)) + pen.setWidthF(0.5) + pen.setCosmetic(True) + + x = rect.left() - (rect.left() % step) + while x <= rect.right(): + self._scene.addLine(x, rect.top(), x, rect.bottom(), pen) + x += step + y = rect.top() - (rect.top() % step) + while y <= rect.bottom(): + self._scene.addLine(rect.left(), y, rect.right(), y, pen) + y += step + + # Centerline (y_cl = 0) — más visible + cl_pen = QPen(QColor(C_CYAN_DEEP)) + cl_pen.setWidthF(0.8) + cl_pen.setStyle(Qt.DashLine) + cl_pen.setCosmetic(True) + self._scene.addLine(rect.left(), 0, rect.right(), 0, cl_pen) + + # Pp (x=0) — más visible + self._scene.addLine(0, rect.top(), 0, rect.bottom(), cl_pen) + + def _draw_vessel_plan(self, vessel) -> None: + L = vessel.length_overall_m * self._px_per_m + B = vessel.beam_max_m * self._px_per_m + b2 = B / 2 + + poly = QPolygonF( + [ + # Stern (popa) — square-ish + QPointF(0, -b2 * 0.85), + QPointF(0, b2 * 0.85), + # Mid-stern out to full beam + QPointF(L * 0.18, b2), + # Parallel mid-body + QPointF(L * 0.70, b2), + # Bow taper + QPointF(L * 0.92, b2 * 0.55), + QPointF(L, 0), + QPointF(L * 0.92, -b2 * 0.55), + QPointF(L * 0.70, -b2), + QPointF(L * 0.18, -b2), + ] + ) + + grad = QLinearGradient(0, -b2, 0, b2) + grad.setColorAt(0.0, QColor(C_SAND)) + grad.setColorAt(0.5, QColor("#94A3B8")) + grad.setColorAt(1.0, QColor(C_FOG)) + brush = QBrush(grad) + pen = QPen(QColor(C_ABYSS)) + pen.setWidthF(2) + pen.setCosmetic(True) + self._scene.addPolygon(poly, pen, brush) + + def _draw_bulkhead(self, x_px: float, beam_m: float, name: str) -> None: + b2 = beam_m * self._px_per_m / 2 + pen = QPen(QColor(C_CYAN)) + pen.setWidthF(1.5) + pen.setStyle(Qt.DashLine) + pen.setCosmetic(True) + self._scene.addLine(x_px, -b2, x_px, b2, pen) + text = self._scene.addText(name, ui_font(8)) + text.setDefaultTextColor(QColor(C_CYAN)) + text.setPos(x_px - text.boundingRect().width() / 2, b2 + 6) + + def _draw_equipment(self, eq) -> None: + center = ship_to_scene(eq.location, self._px_per_m) + + # color por sistema (simplificado: motor cyan, genset amber, otros sand) + sys_v = eq.system_id.value + if sys_v == "main_engine": + color = QColor(C_CYAN) + elif sys_v == "genset": + color = QColor(C_WARN) + else: + color = QColor(C_SAND) + + # Halo (the returned QGraphicsItem stays owned by the scene) + self._scene.addEllipse( + center.x() - 14, + center.y() - 14, + 28, + 28, + QPen(color, 1.2, Qt.SolidLine), + QBrush(QColor(color.red(), color.green(), color.blue(), 40)), + ) + # Dot + self._scene.addEllipse( + center.x() - 6, + center.y() - 6, + 12, + 12, + QPen(QColor(C_ABYSS), 1.5), + QBrush(color), + ) + # Label + text = self._scene.addText(eq.tag_prefix, ui_font(8)) + text.setDefaultTextColor(QColor(C_FOAM)) + text.setPos(center.x() - text.boundingRect().width() / 2, center.y() + 12) + + def _draw_axes(self, length_m: float, beam_m: float) -> None: + # Ruler X + pen = QPen(QColor(C_FOG)) + pen.setCosmetic(True) + ruler_y = (beam_m / 2 + 1.5) * self._px_per_m + L = length_m * self._px_per_m + self._scene.addLine(0, ruler_y, L, ruler_y, pen) + for m in range(0, int(length_m) + 1, 5): + x = m * self._px_per_m + self._scene.addLine(x, ruler_y - 4, x, ruler_y + 4, pen) + t = self._scene.addText(f"{m} m", mono_font(7)) + t.setDefaultTextColor(QColor(C_FOG)) + t.setPos(x - t.boundingRect().width() / 2, ruler_y + 6) + label = self._scene.addText("x_pp →", mono_font(8)) + label.setDefaultTextColor(QColor(C_CYAN)) + label.setPos(L + 12, ruler_y - 8) + + # Pp arrow / origin + origin_label = self._scene.addText("Pp", mono_font(9)) + origin_label.setDefaultTextColor(QColor(C_CYAN)) + origin_label.setPos(-20, ruler_y - 8) + + # ----- Slots -------------------------------------------------------- + + def _on_zoom_changed(self, scale: float) -> None: + self._zoom_label.setText(f"Zoom: {scale * 100:.0f}%") + + +class _VesselGraphicsView(QGraphicsView): + """QGraphicsView con zoom por wheel + scroll-pan.""" + + from PySide6.QtCore import Signal # local import to avoid double importing + + zoomChanged = Signal(float) + + def __init__(self, scene: QGraphicsScene, parent: QWidget | None = None) -> None: + super().__init__(scene, parent) + self._scale = 1.0 + self.setMouseTracking(True) + + def wheelEvent(self, event) -> None: # type: ignore[override] + delta = event.angleDelta().y() + if delta == 0: + return + factor = 1.15 if delta > 0 else 1 / 1.15 + new_scale = self._scale * factor + if not (0.1 <= new_scale <= 10.0): + return + self._scale = new_scale + self.scale(factor, factor) + self.zoomChanged.emit(self._scale) diff --git a/vmssailor/studio/wizard/__init__.py b/vmssailor/studio/wizard/__init__.py new file mode 100644 index 0000000..9d4336a --- /dev/null +++ b/vmssailor/studio/wizard/__init__.py @@ -0,0 +1,9 @@ +"""Wizard de creación de proyecto de buque (Studio). + +Sprint 1: pasos 1-4 funcionales + 5-8 placeholders. +Sprint 2: pasos 5-8 implementados con rule_engine. +""" + +from vmssailor.studio.wizard.wizard import VesselWizard + +__all__ = ["VesselWizard"] diff --git a/vmssailor/studio/wizard/step_01_vessel_type.py b/vmssailor/studio/wizard/step_01_vessel_type.py new file mode 100644 index 0000000..9550e0e --- /dev/null +++ b/vmssailor/studio/wizard/step_01_vessel_type.py @@ -0,0 +1,131 @@ +"""Paso 1: tipo y subtipo del buque.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QComboBox, + QFormLayout, + QLabel, + QLineEdit, + QVBoxLayout, + QWidget, + QWizardPage, +) + +from vmssailor.core.enums import VesselSubtype, VesselType +from vmssailor.studio.theme import C_FOG, ui_font + +_SUBTYPES_BY_TYPE = { + VesselType.YACHT_MOTOR: [ + VesselSubtype.PLANING, + VesselSubtype.SEMI_PLANING, + VesselSubtype.DISPLACEMENT, + ], + VesselType.YACHT_SAIL: [VesselSubtype.DISPLACEMENT], + VesselType.FISHING: [ + VesselSubtype.PURSE_SEINER, + VesselSubtype.TRAWLER, + VesselSubtype.LONGLINER, + ], + VesselType.PATROL: [VesselSubtype.COASTAL, VesselSubtype.OCEANIC], + VesselType.FERRY: [VesselSubtype.PASSENGER, VesselSubtype.ROLL_ON_ROLL_OFF], + VesselType.OFFSHORE_SUPPORT: [VesselSubtype.AHTS, VesselSubtype.PSV], +} + + +_TYPE_LABELS = { + VesselType.YACHT_MOTOR: "Yate motor", + VesselType.YACHT_SAIL: "Yate vela", + VesselType.FISHING: "Pesquero", + VesselType.PATROL: "Patrullero", + VesselType.FERRY: "Ferry", + VesselType.OFFSHORE_SUPPORT: "Offshore support", +} + +_SUBTYPE_LABELS = { + VesselSubtype.PLANING: "Planeo", + VesselSubtype.SEMI_PLANING: "Semi-planeo", + VesselSubtype.DISPLACEMENT: "Desplazamiento", + VesselSubtype.PURSE_SEINER: "Cerquero", + VesselSubtype.TRAWLER: "Arrastrero", + VesselSubtype.LONGLINER: "Palangrero", + VesselSubtype.COASTAL: "Costero", + VesselSubtype.OCEANIC: "Oceánico", + VesselSubtype.PASSENGER: "Pasajeros", + VesselSubtype.ROLL_ON_ROLL_OFF: "Ro-ro", + VesselSubtype.AHTS: "AHTS", + VesselSubtype.PSV: "PSV", + VesselSubtype.OTHER: "Otro", +} + + +class Step01VesselType(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Paso 1 · Tipo de buque") + self.setSubTitle( + "Elige la categoría principal del buque y su subcategoría. " + "Esto filtra las plantillas disponibles en el paso 2." + ) + + layout = QVBoxLayout(self) + layout.setSpacing(18) + + # Project basics + intro = QLabel( + "Cada proyecto VMS-Sailor corresponde a UN buque de UN cliente.\n" + "Aquí defines lo básico para empezar; el wizard te guiará por los demás pasos." + ) + intro.setWordWrap(True) + intro.setStyleSheet(f"color: {C_FOG};") + intro.setFont(ui_font(10)) + layout.addWidget(intro) + + form = QFormLayout() + form.setHorizontalSpacing(20) + form.setVerticalSpacing(12) + + self._project_name = QLineEdit() + self._project_name.setPlaceholderText('Ej: M/Y Aurora — Sunseeker 76') + form.addRow("Nombre del proyecto:", self._project_name) + + self._customer = QLineEdit() + self._customer.setPlaceholderText("Ej: Acme Yachts S.A.") + form.addRow("Cliente / Armador:", self._customer) + + self._type_combo = QComboBox() + for vt in VesselType: + self._type_combo.addItem(_TYPE_LABELS[vt], vt.value) + form.addRow("Tipo de buque:", self._type_combo) + + self._subtype_combo = QComboBox() + form.addRow("Subcategoría:", self._subtype_combo) + + layout.addLayout(form) + layout.addStretch(1) + + # Register fields (no asterisk so they're optional at validation time; + # we provide defaults during build_project). + from vmssailor.studio.wizard.wizard import ( + F_CUSTOMER, + F_PROJECT_NAME, + F_VESSEL_SUBTYPE, + F_VESSEL_TYPE, + ) + + self.registerField(F_PROJECT_NAME + "*", self._project_name) + self.registerField(F_CUSTOMER, self._customer) + self.registerField(F_VESSEL_TYPE, self._type_combo, "currentData", "currentIndexChanged") + self.registerField( + F_VESSEL_SUBTYPE, self._subtype_combo, "currentData", "currentIndexChanged" + ) + + self._type_combo.currentIndexChanged.connect(self._populate_subtypes) + self._populate_subtypes() + + def _populate_subtypes(self) -> None: + self._subtype_combo.clear() + v_type = VesselType(self._type_combo.currentData()) + subs = _SUBTYPES_BY_TYPE.get(v_type, [VesselSubtype.OTHER]) + for s in subs: + self._subtype_combo.addItem(_SUBTYPE_LABELS[s], s.value) diff --git a/vmssailor/studio/wizard/step_02_template.py b/vmssailor/studio/wizard/step_02_template.py new file mode 100644 index 0000000..72a81e7 --- /dev/null +++ b/vmssailor/studio/wizard/step_02_template.py @@ -0,0 +1,167 @@ +"""Paso 2: selección de plantilla de biblioteca curada.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QVBoxLayout, + QWidget, + QWizardPage, +) + +from vmssailor.library import load_library +from vmssailor.studio.theme import C_FOG, mono_font, ui_font + + +class Step02Template(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Paso 2 · Plantilla de buque") + self.setSubTitle( + "Elige una plantilla de la biblioteca curada. Las dimensiones del paso 3 " + "se pre-rellenarán pero puedes ajustar después." + ) + + outer = QHBoxLayout(self) + outer.setSpacing(20) + + # Left: list of templates filtered by type + self._list = QListWidget() + self._list.setMinimumWidth(280) + outer.addWidget(self._list, 1) + + # Right: details + right = QVBoxLayout() + outer.addLayout(right, 2) + + self._name_label = QLabel("(Selecciona una plantilla)") + self._name_label.setObjectName("title") + self._name_label.setFont(ui_font(13)) + right.addWidget(self._name_label) + + self._meta_label = QLabel("") + self._meta_label.setFont(mono_font(10)) + self._meta_label.setStyleSheet(f"color: {C_FOG};") + right.addWidget(self._meta_label) + + self._desc_label = QLabel("") + self._desc_label.setWordWrap(True) + right.addWidget(self._desc_label) + + right.addStretch(1) + + # Hidden vessel_name field for the next step + self._vessel_name = QLineEdit() + self._vessel_name.hide() + + from vmssailor.studio.wizard.wizard import F_TEMPLATE_ID, F_VESSEL_NAME + + self.registerField(F_TEMPLATE_ID, self, "selected_template_id", "templateChanged") + self.registerField(F_VESSEL_NAME, self._vessel_name) + + self._templates: dict[str, dict] = {} + self._selected_id: str | None = None + self._load_templates() + + self._list.currentItemChanged.connect(self._on_selection_changed) + + # ----- Property API (for QWizard field) ------------------------------- + + from PySide6.QtCore import Property, Signal # type: ignore + + templateChanged = Signal() + + def get_selected_template_id(self) -> str: + return self._selected_id or "" + + def set_selected_template_id(self, value: str) -> None: + self._selected_id = value + self.templateChanged.emit() + + selected_template_id = Property( + str, fget=get_selected_template_id, fset=set_selected_template_id, notify=templateChanged + ) + + # ----- Internals ------------------------------------------------------ + + def initializePage(self) -> None: + from vmssailor.studio.wizard.wizard import F_VESSEL_TYPE + + type_str = self.field(F_VESSEL_TYPE) + self._refresh_list(type_str) + + def _load_templates(self) -> None: + try: + result = load_library() + for v in result.vessels: + self._templates[v.id] = { + "id": v.id, + "name": v.name, + "type": v.type.value, + "length_overall_m": v.length_overall_m, + "beam_max_m": v.beam_max_m, + "draft_m": v.draft_m, + "description": v.description, + } + except Exception: + # Si la biblioteca falla, no rompemos el wizard. + pass + + # Also add a "blank" template + self._templates["__blank__"] = { + "id": "__blank__", + "name": "Plantilla en blanco", + "type": "*", + "length_overall_m": 24.0, + "beam_max_m": 5.5, + "draft_m": 1.8, + "description": "Empezar desde cero, sin plantilla de buque. Defines todas las dimensiones manualmente.", + } + + def _refresh_list(self, type_filter: str | None) -> None: + self._list.clear() + for tid, t in self._templates.items(): + if type_filter and t["type"] not in (type_filter, "*"): + continue + label = ( + f"{t['name']}\n" + f" {t['length_overall_m']:.1f} × {t['beam_max_m']:.1f} m" + if tid != "__blank__" + else t["name"] + ) + item = QListWidgetItem(label) + item.setData(Qt.UserRole, tid) + self._list.addItem(item) + if self._list.count() > 0: + self._list.setCurrentRow(0) + + def _on_selection_changed(self, current: QListWidgetItem | None) -> None: + if not current: + return + tid = current.data(Qt.UserRole) + t = self._templates.get(tid) + if not t: + return + self._selected_id = tid + self._vessel_name.setText(t["name"]) + self._name_label.setText(t["name"]) + self._meta_label.setText( + f"id: {t['id']} LOA {t['length_overall_m']:.1f} m · " + f"manga {t['beam_max_m']:.1f} m · calado {t['draft_m']:.1f} m" + ) + self._desc_label.setText(t.get("description", "") or "(sin descripción)") + self.templateChanged.emit() + self.completeChanged.emit() + + def isComplete(self) -> bool: + return self._selected_id is not None + + def get_template(self) -> dict | None: + if self._selected_id: + return self._templates.get(self._selected_id) + return None diff --git a/vmssailor/studio/wizard/step_03_dimensions.py b/vmssailor/studio/wizard/step_03_dimensions.py new file mode 100644 index 0000000..9e8a3b2 --- /dev/null +++ b/vmssailor/studio/wizard/step_03_dimensions.py @@ -0,0 +1,105 @@ +"""Paso 3: dimensiones físicas del buque.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDoubleSpinBox, + QFormLayout, + QLabel, + QVBoxLayout, + QWidget, + QWizardPage, +) + +from vmssailor.studio.theme import C_FOG, ui_font + + +class Step03Dimensions(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Paso 3 · Dimensiones principales") + self.setSubTitle( + "Edita las dimensiones principales del buque. Los valores se pre-rellenan " + "desde la plantilla del paso 2 si elegiste una." + ) + + layout = QVBoxLayout(self) + layout.setSpacing(18) + + intro = QLabel( + "Todas las medidas en metros (sistema internacional). " + "Estos valores se usan en core para validar coordenadas de equipos y tarjetas." + ) + intro.setWordWrap(True) + intro.setStyleSheet(f"color: {C_FOG};") + intro.setFont(ui_font(10)) + layout.addWidget(intro) + + form = QFormLayout() + form.setHorizontalSpacing(20) + form.setVerticalSpacing(14) + + self._loa = self._make_spin(1.0, 200.0, 24.0, " m", 1) + form.addRow("Eslora total (LOA):", self._loa) + + self._beam = self._make_spin(1.0, 30.0, 5.5, " m", 2) + form.addRow("Manga máxima:", self._beam) + + self._draft = self._make_spin(0.3, 10.0, 1.8, " m", 2) + form.addRow("Calado:", self._draft) + + self._bh_aft = self._make_spin(0.0, 200.0, 4.0, " m desde Pp", 1) + form.addRow("Mamparo popa sala máquinas:", self._bh_aft) + + self._bh_fwd = self._make_spin(0.0, 200.0, 8.0, " m desde Pp", 1) + form.addRow("Mamparo proa sala máquinas:", self._bh_fwd) + + layout.addLayout(form) + layout.addStretch(1) + + from vmssailor.studio.wizard.wizard import ( + F_BEAM, + F_BULKHEAD_AFT, + F_BULKHEAD_FWD, + F_DRAFT, + F_LOA, + ) + + self.registerField(F_LOA, self._loa, "value", "valueChanged") + self.registerField(F_BEAM, self._beam, "value", "valueChanged") + self.registerField(F_DRAFT, self._draft, "value", "valueChanged") + self.registerField(F_BULKHEAD_AFT, self._bh_aft, "value", "valueChanged") + self.registerField(F_BULKHEAD_FWD, self._bh_fwd, "value", "valueChanged") + + def _make_spin(self, lo: float, hi: float, default: float, suffix: str, decimals: int) -> QDoubleSpinBox: + s = QDoubleSpinBox() + s.setRange(lo, hi) + s.setDecimals(decimals) + s.setSingleStep(0.1 if decimals >= 1 else 1) + s.setSuffix(suffix) + s.setValue(default) + s.setMinimumWidth(180) + return s + + def initializePage(self) -> None: + # If the user picked a template in step 2, pre-fill from it. + from vmssailor.studio.wizard.wizard import F_TEMPLATE_ID + + wizard = self.wizard() + tpl_id = self.field(F_TEMPLATE_ID) + if not tpl_id or not wizard: + return + # Look up the step 2 page to get the template dict + from vmssailor.studio.wizard.step_02_template import Step02Template + + for pid in range(8): + page = wizard.page(pid) + if isinstance(page, Step02Template): + tpl = page.get_template() + if tpl: + self._loa.setValue(tpl["length_overall_m"]) + self._beam.setValue(tpl["beam_max_m"]) + self._draft.setValue(tpl["draft_m"]) + self._bh_aft.setValue(max(2.0, tpl["length_overall_m"] * 0.15)) + self._bh_fwd.setValue(max(5.0, tpl["length_overall_m"] * 0.30)) + break diff --git a/vmssailor/studio/wizard/step_04_systems.py b/vmssailor/studio/wizard/step_04_systems.py new file mode 100644 index 0000000..dd68608 --- /dev/null +++ b/vmssailor/studio/wizard/step_04_systems.py @@ -0,0 +1,152 @@ +"""Paso 4: selección de sistemas habilitados. + +Lee el catálogo maestro de sistemas y muestra checkboxes agrupados por categoría. +Pre-selecciona los sistemas marcados como `default_for` el VesselType del paso 1. +""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QCheckBox, + QFrame, + QGridLayout, + QGroupBox, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, + QWizardPage, +) + +from vmssailor.library import load_systems_catalog +from vmssailor.studio.theme import C_FOG, ui_font + + +class Step04Systems(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Paso 4 · Sistemas instalados") + self.setSubTitle( + "Marca los sistemas que tendrá el buque. Esto define el menú lateral del Runtime y " + "qué tags se generarán en sprints siguientes." + ) + + outer = QVBoxLayout(self) + outer.setSpacing(10) + + header = QLabel( + "Pre-selección automática según tipo de buque. Ajusta marcando/desmarcando." + ) + header.setWordWrap(True) + header.setStyleSheet(f"color: {C_FOG};") + header.setFont(ui_font(10)) + outer.addWidget(header) + + # Buttons row + btn_row = QGridLayout() + self._btn_select_defaults = QPushButton("Resetear a defaults del tipo") + self._btn_select_all = QPushButton("Marcar todos") + self._btn_select_none = QPushButton("Desmarcar todos") + btn_row.addWidget(self._btn_select_defaults, 0, 0) + btn_row.addWidget(self._btn_select_all, 0, 1) + btn_row.addWidget(self._btn_select_none, 0, 2) + outer.addLayout(btn_row) + + self._btn_select_defaults.clicked.connect(self._select_defaults) + self._btn_select_all.clicked.connect(self._select_all) + self._btn_select_none.clicked.connect(self._select_none) + + # Scroll area with category groups + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + outer.addWidget(scroll, 1) + + content = QWidget() + self._content_layout = QVBoxLayout(content) + self._content_layout.setSpacing(14) + scroll.setWidget(content) + + # Build categories + self._checkboxes: dict[str, QCheckBox] = {} + self._catalog = self._load_catalog_safe() + self._build_categories() + + # Register field + from vmssailor.studio.wizard.wizard import F_SYSTEMS + + self.registerField(F_SYSTEMS, self, "enabled_systems", "systemsChanged") + + # ----- Property API ------------------------------------------------ + + from PySide6.QtCore import Property, Signal + + systemsChanged = Signal() + + def get_enabled_systems(self) -> list[str]: + return [sid for sid, cb in self._checkboxes.items() if cb.isChecked()] + + def set_enabled_systems(self, value: list[str]) -> None: + for sid, cb in self._checkboxes.items(): + cb.setChecked(sid in value) + self.systemsChanged.emit() + + enabled_systems = Property( + list, + fget=get_enabled_systems, + fset=set_enabled_systems, + notify=systemsChanged, + ) + + # ----- Internals --------------------------------------------------- + + def _load_catalog_safe(self) -> dict: + try: + return load_systems_catalog() + except Exception: + return {"categories": []} + + def _build_categories(self) -> None: + for cat in self._catalog.get("categories", []): + group = QGroupBox(cat.get("name", "—")) + grid = QGridLayout(group) + grid.setHorizontalSpacing(20) + grid.setVerticalSpacing(6) + for idx, sys in enumerate(cat.get("systems", [])): + cb = QCheckBox(sys.get("name", sys["id"])) + cb.setToolTip(f"id: {sys['id']}") + cb.stateChanged.connect(self.systemsChanged.emit) + self._checkboxes[sys["id"]] = cb + grid.addWidget(cb, idx // 3, idx % 3) + self._content_layout.addWidget(group) + self._content_layout.addStretch(1) + + def _system_default_for(self, sys_id: str) -> list[str]: + for cat in self._catalog.get("categories", []): + for s in cat.get("systems", []): + if s.get("id") == sys_id: + return s.get("default_for", []) + return [] + + def _select_defaults(self) -> None: + from vmssailor.studio.wizard.wizard import F_VESSEL_TYPE + + v_type = self.field(F_VESSEL_TYPE) + if not v_type: + return + for sid, cb in self._checkboxes.items(): + cb.setChecked(v_type in self._system_default_for(sid)) + + def _select_all(self) -> None: + for cb in self._checkboxes.values(): + cb.setChecked(True) + + def _select_none(self) -> None: + for cb in self._checkboxes.values(): + cb.setChecked(False) + + def initializePage(self) -> None: + # On first entry, pre-select defaults for the chosen vessel type. + if not any(cb.isChecked() for cb in self._checkboxes.values()): + self._select_defaults() diff --git a/vmssailor/studio/wizard/step_08_confirm.py b/vmssailor/studio/wizard/step_08_confirm.py new file mode 100644 index 0000000..e813247 --- /dev/null +++ b/vmssailor/studio/wizard/step_08_confirm.py @@ -0,0 +1,134 @@ +"""Paso 8: confirmación final con resumen del proyecto.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget, QWizardPage + +from vmssailor.studio.theme import ( + C_FOAM, + C_FOG, + C_MIDNIGHT, + C_OK, + C_SAND, + C_STEEL, + ui_font, +) + + +class Step08Confirm(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Paso 8 · Confirmación") + self.setSubTitle( + "Revisa el resumen del proyecto. Pulsa «Crear proyecto» para abrirlo en el editor." + ) + + layout = QVBoxLayout(self) + layout.setSpacing(16) + + self._summary_card = QLabel() + self._summary_card.setWordWrap(True) + self._summary_card.setTextFormat(Qt.RichText) + self._summary_card.setFont(ui_font(11)) + self._summary_card.setStyleSheet( + f""" + background: {C_MIDNIGHT}; + border: 1px solid {C_STEEL}; + border-radius: 12px; + padding: 20px; + color: {C_SAND}; + """ + ) + layout.addWidget(self._summary_card, 1) + + footer = QLabel( + "Una vez creado el proyecto se abre en el editor principal del Studio. " + "Podrás agregar equipos, tags y tarjetas, y guardar el .vmsproj cuando quieras.\n\n" + "Sprint 2 traerá: editor de equipos, motor de reglas, asignación de I/O." + ) + footer.setWordWrap(True) + footer.setStyleSheet(f"color: {C_FOG};") + footer.setFont(ui_font(9)) + layout.addWidget(footer) + + def initializePage(self) -> None: + from vmssailor.studio.wizard.wizard import ( + F_BEAM, + F_BULKHEAD_AFT, + F_BULKHEAD_FWD, + F_CUSTOMER, + F_DRAFT, + F_LOA, + F_PROJECT_NAME, + F_SYSTEMS, + F_TEMPLATE_ID, + F_VESSEL_NAME, + F_VESSEL_SUBTYPE, + F_VESSEL_TYPE, + ) + + project_name = self.field(F_PROJECT_NAME) or "(sin nombre)" + customer = self.field(F_CUSTOMER) or "(sin cliente)" + v_type = self.field(F_VESSEL_TYPE) or "—" + v_sub = self.field(F_VESSEL_SUBTYPE) or "—" + template = self.field(F_TEMPLATE_ID) or "—" + v_name = self.field(F_VESSEL_NAME) or "—" + loa = self.field(F_LOA) or 0.0 + beam = self.field(F_BEAM) or 0.0 + draft = self.field(F_DRAFT) or 0.0 + bh_aft = self.field(F_BULKHEAD_AFT) or 0.0 + bh_fwd = self.field(F_BULKHEAD_FWD) or 0.0 + systems = self.field(F_SYSTEMS) or [] + + n_sys = len(systems) + systems_str = ", ".join(systems[:10]) + if n_sys > 10: + systems_str += f", … (+{n_sys - 10})" + if not systems_str: + systems_str = "(ninguno seleccionado)" + + html = f""" +

+ {project_name} +

+

+ Cliente: {customer} +

+ +
+
+
Buque
+
{v_name}
+
+ {v_type} · {v_sub} +
+
+ Plantilla: {template} +
+
+
+ + + + + + + +
Eslora{loa:.1f} m
Manga{beam:.1f} m
Calado{draft:.1f} m
Mamparo popa SMx_pp {bh_aft:.1f} m
Mamparo proa SMx_pp {bh_fwd:.1f} m
+ +
+ +
Sistemas habilitados ({n_sys})
+
{systems_str}
+ +

+ ✓ El proyecto se creará sin equipos. Agrega equipos manualmente o + espera Sprint 2 (motor de reglas). +

+ """ + self._summary_card.setText(html) diff --git a/vmssailor/studio/wizard/step_57_placeholder.py b/vmssailor/studio/wizard/step_57_placeholder.py new file mode 100644 index 0000000..a5c1ba1 --- /dev/null +++ b/vmssailor/studio/wizard/step_57_placeholder.py @@ -0,0 +1,46 @@ +"""Placeholder para pasos 5, 6, 7 — se implementan en Sprint 2.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget, QWizardPage + +from vmssailor.studio.theme import C_CYAN, C_FOG, C_SAND, display_font, ui_font + + +class Step57Placeholder(QWizardPage): + """Placeholder visual con explicación clara de qué viene en Sprint 2.""" + + def __init__(self, *, index: int, title: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle(f"Paso {index} · {title}") + self.setSubTitle("Este paso se implementa en Sprint 2. Por ahora se omite.") + + layout = QVBoxLayout(self) + layout.setSpacing(16) + layout.addStretch(1) + + big = QLabel(f"Paso {index}") + big.setFont(display_font(48, 700)) + big.setStyleSheet(f"color: {C_CYAN};") + big.setAlignment(Qt.AlignCenter) + layout.addWidget(big) + + msg = QLabel(title) + msg.setFont(ui_font(14)) + msg.setStyleSheet(f"color: {C_SAND};") + msg.setAlignment(Qt.AlignCenter) + layout.addWidget(msg) + + body = QLabel( + "El motor de reglas heurísticas (yacht_motor_planeo.yaml ya está en biblioteca)\n" + "propondrá equipos automáticamente para los sistemas habilitados.\n\n" + "En Sprint 1 se omite — el proyecto se crea con sistemas pero sin equipos todavía.\n" + "Equipos se pueden agregar manualmente desde el editor en Sprint 2." + ) + body.setFont(ui_font(10)) + body.setStyleSheet(f"color: {C_FOG};") + body.setAlignment(Qt.AlignCenter) + layout.addWidget(body) + + layout.addStretch(2) diff --git a/vmssailor/studio/wizard/wizard.py b/vmssailor/studio/wizard/wizard.py new file mode 100644 index 0000000..7ae6242 --- /dev/null +++ b/vmssailor/studio/wizard/wizard.py @@ -0,0 +1,147 @@ +"""QWizard contenedor + builder de Project a partir del estado del wizard.""" + +from __future__ import annotations + +import logging + +from PySide6.QtWidgets import QWidget, QWizard + +from vmssailor.core import ( + Project, + SystemId, + Vessel, + VesselSubtype, + VesselType, +) +from vmssailor.core.vessel import Bulkhead, Deck +from vmssailor.shared.ids import make_project_id +from vmssailor.studio.theme import ( + C_ABYSS, + C_SAND, +) +from vmssailor.studio.wizard.step_01_vessel_type import Step01VesselType +from vmssailor.studio.wizard.step_02_template import Step02Template +from vmssailor.studio.wizard.step_03_dimensions import Step03Dimensions +from vmssailor.studio.wizard.step_04_systems import Step04Systems +from vmssailor.studio.wizard.step_08_confirm import Step08Confirm +from vmssailor.studio.wizard.step_57_placeholder import Step57Placeholder + +logger = logging.getLogger(__name__) + + +# Wizard field keys used across steps +F_PROJECT_NAME = "project.name" +F_CUSTOMER = "project.customer" +F_VESSEL_TYPE = "vessel.type" +F_VESSEL_SUBTYPE = "vessel.subtype" +F_TEMPLATE_ID = "vessel.template_id" +F_VESSEL_NAME = "vessel.name" +F_LOA = "vessel.length_overall_m" +F_BEAM = "vessel.beam_max_m" +F_DRAFT = "vessel.draft_m" +F_BULKHEAD_FWD = "vessel.bulkhead_fwd_m" +F_BULKHEAD_AFT = "vessel.bulkhead_aft_m" +F_SYSTEMS = "systems.enabled" + + +class VesselWizard(QWizard): + """Wizard 8 pasos para crear un nuevo Project desde cero.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("VMS-Sailor · Nuevo proyecto") + self.setMinimumSize(900, 640) + self.setWizardStyle(QWizard.ModernStyle) + self.setOption(QWizard.NoBackButtonOnStartPage, True) + self.setOption(QWizard.HaveHelpButton, False) + self.setOption(QWizard.IndependentPages, False) + + # Add pages + self.addPage(Step01VesselType()) + self.addPage(Step02Template()) + self.addPage(Step03Dimensions()) + self.addPage(Step04Systems()) + self.addPage(Step57Placeholder(index=5, title="Equipos sugeridos (Sprint 2)")) + self.addPage(Step57Placeholder(index=6, title="Refinamiento manual (Sprint 2)")) + self.addPage(Step57Placeholder(index=7, title="Topología I/O AR-NMEA-IO (Sprint 2)")) + self.addPage(Step08Confirm()) + + # Style + self.setStyleSheet( + f""" + QWizard {{ background: {C_ABYSS}; }} + QWidget {{ background: {C_ABYSS}; color: {C_SAND}; }} + QWizard QPushButton {{ min-width: 100px; }} + """ + ) + + # Localización de botones (QWizard usa textos del sistema) + self.setButtonText(QWizard.NextButton, "Siguiente >") + self.setButtonText(QWizard.BackButton, "< Atrás") + self.setButtonText(QWizard.FinishButton, "Crear proyecto") + self.setButtonText(QWizard.CancelButton, "Cancelar") + + # ----- Project construction ----------------------------------------- + + def build_project(self) -> Project: + """Construye un `Project` a partir de los datos capturados por el wizard.""" + project_name = self.field(F_PROJECT_NAME) or "Proyecto sin nombre" + customer = self.field(F_CUSTOMER) or "" + vessel_name = self.field(F_VESSEL_NAME) or project_name + v_type = self.field(F_VESSEL_TYPE) or VesselType.YACHT_MOTOR.value + v_sub = self.field(F_VESSEL_SUBTYPE) or VesselSubtype.PLANING.value + loa = float(self.field(F_LOA) or 24.0) + beam = float(self.field(F_BEAM) or 5.5) + draft = float(self.field(F_DRAFT) or 1.8) + bh_fwd = float(self.field(F_BULKHEAD_FWD) or (loa * 0.30)) + bh_aft = float(self.field(F_BULKHEAD_AFT) or (loa * 0.15)) + systems_raw = self.field(F_SYSTEMS) or [SystemId.MAIN_ENGINE.value] + + # Build vessel + decks = [ + Deck(id="lower", name="Cubierta inferior", z_bl_bottom=0.4, z_bl_top=draft + 1.0), + Deck( + id="main", + name="Cubierta principal", + z_bl_bottom=draft + 1.0, + z_bl_top=draft + 3.0, + ), + ] + bulkheads = [ + Bulkhead(id="er_aft", name="Mamparo popa SM", x_pp=bh_aft), + Bulkhead(id="er_fwd", name="Mamparo proa SM", x_pp=bh_fwd), + ] + try: + vessel = Vessel( + id=f"wizard_{int(loa * 10)}m", + name=vessel_name, + type=VesselType(v_type), + subtype=VesselSubtype(v_sub), + length_overall_m=loa, + beam_max_m=beam, + draft_m=draft, + decks=decks, + bulkheads=bulkheads, + data_source="user_input", + ) + except Exception as exc: + logger.error("Vessel build failed: %s", exc) + raise + + # Equipment + tags llegan en Sprint 2 (rule engine). Por ahora, vacío. + try: + systems_enabled = [SystemId(s) for s in systems_raw] + except ValueError: + systems_enabled = [SystemId.MAIN_ENGINE] + + project_id = make_project_id(customer or "cliente", vessel.id) + return Project( + id=project_id, + name=project_name, + customer=customer, + vessel=vessel, + systems_enabled=systems_enabled, + equipment=[], + tags=[], + notes="Creado desde wizard Sprint 1. Sprint 2 agregará equipos sugeridos.", + )