feat: AR-VMS-Seaman initial commit — Python FastAPI + PySide6 (runtime server + desktop studio client)

This commit is contained in:
2026-07-03 12:16:31 -04:00
parent 7390d5cd51
commit 2302e963b2
12 changed files with 1144 additions and 136 deletions
Binary file not shown.
+12 -6
View File
@@ -5,13 +5,19 @@
<stop offset="0%" stop-color="#00D9FF"/> <stop offset="0%" stop-color="#00D9FF"/>
<stop offset="100%" stop-color="#1B7FB5"/> <stop offset="100%" stop-color="#1B7FB5"/>
</linearGradient> </linearGradient>
<linearGradient id="favHull" x1="0%" y1="0%" x2="0%" y2="1">
<stop offset="0%" stop-color="#F2F5F9"/>
<stop offset="100%" stop-color="#7C8B9F"/>
</linearGradient>
</defs> </defs>
<rect width="64" height="64" rx="14" fill="#04111F"/> <rect width="64" height="64" rx="14" fill="#04111F"/>
<circle cx="32" cy="32" r="22" fill="none" stroke="url(#favCyan)" stroke-width="2.4"/> <circle cx="32" cy="32" r="22" fill="none" stroke="url(#favCyan)" stroke-width="2.2"/>
<g stroke="#E6EAF0" stroke-width="1.4" stroke-linecap="round"> <path d="M 32 10 L 30 6 L 34 6 Z" fill="#00D9FF"/>
<line x1="32" y1="9" x2="32" y2="55"/> <g transform="translate(32,33) scale(0.55)">
<line x1="9" y1="32" x2="55" y2="32"/> <path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z" fill="url(#favHull)" stroke="#04111F" stroke-width="0.8"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.6"/>
<path d="M -2 -14 L 0 -19 L 10 -19 L 12 -14 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.5"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
<line x1="5" y1="-19" x2="5" y2="-25" stroke="#7C8B9F" stroke-width="0.8"/>
</g> </g>
<path d="M 17 36 Q 20 30 32 30 Q 44 30 47 36 L 42 42 L 22 42 Z" fill="#E6EAF0"/>
<path d="M 31 30 L 31 18 L 43 30 Z" fill="#00D9FF"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 778 B

After

Width:  |  Height:  |  Size: 1.3 KiB

+90 -16
View File
@@ -1,32 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96" role="img" aria-label="VMS-Sailor mark"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120" role="img" aria-label="VMS-Sailor mark">
<defs> <defs>
<linearGradient id="m_cyan" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="m_cyan" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00D9FF"/> <stop offset="0%" stop-color="#00D9FF"/>
<stop offset="60%" stop-color="#5BC0EB"/> <stop offset="60%" stop-color="#5BC0EB"/>
<stop offset="100%" stop-color="#1B7FB5"/> <stop offset="100%" stop-color="#1B7FB5"/>
</linearGradient> </linearGradient>
<linearGradient id="m_hull" x1="0%" y1="0%" x2="0%" y2="100%"> <linearGradient id="m_hull" x1="0%" y1="0%" x2="0%" y2="1">
<stop offset="0%" stop-color="#E6EAF0"/> <stop offset="0%" stop-color="#F2F5F9"/>
<stop offset="55%" stop-color="#C7D0DD"/>
<stop offset="100%" stop-color="#7C8B9F"/> <stop offset="100%" stop-color="#7C8B9F"/>
</linearGradient> </linearGradient>
<linearGradient id="m_super" x1="0%" y1="0%" x2="0%" y2="1">
<stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#B8C2D1"/>
</linearGradient>
<linearGradient id="m_water" x1="0%" y1="0%" x2="0%" y2="1">
<stop offset="0%" stop-color="#00D9FF" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#00D9FF" stop-opacity="0"/>
</linearGradient>
<radialGradient id="m_glow" cx="50%" cy="50%" r="55%"> <radialGradient id="m_glow" cx="50%" cy="50%" r="55%">
<stop offset="0%" stop-color="#00D9FF" stop-opacity="0.65"/> <stop offset="0%" stop-color="#00D9FF" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#00D9FF" stop-opacity="0"/> <stop offset="100%" stop-color="#00D9FF" stop-opacity="0"/>
</radialGradient> </radialGradient>
<filter id="m_softGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="1.2" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs> </defs>
<g transform="translate(48,48)">
<circle r="44" fill="url(#m_glow)"/> <!-- Outer compass / glow -->
<circle r="36" fill="none" stroke="url(#m_cyan)" stroke-width="3"/> <g transform="translate(60,60)">
<g stroke="#E6EAF0" stroke-width="1.4" stroke-linecap="round"> <circle r="56" fill="url(#m_glow)"/>
<line x1="0" y1="-34" x2="0" y2="34"/> <circle r="46" fill="none" stroke="url(#m_cyan)" stroke-width="3"/>
<line x1="-34" y1="0" x2="34" y2="0"/>
<line x1="-24" y1="-24" x2="24" y2="24" opacity="0.4"/> <!-- Compass ticks: 32 short marks every 11.25 degrees -->
<line x1="-24" y1="24" x2="24" y2="-24" opacity="0.4"/> <g stroke="#5BC0EB" stroke-width="1" opacity="0.7">
<line x1="0" y1="-46" x2="0" y2="-42"/>
<line x1="0" y1="46" x2="0" y2="42"/>
<line x1="-46" y1="0" x2="-42" y2="0"/>
<line x1="46" y1="0" x2="42" y2="0"/>
</g>
<!-- 8-point cross -->
<g stroke="#E6EAF0" stroke-width="0.8" opacity="0.45">
<line x1="-32" y1="-32" x2="32" y2="32"/>
<line x1="-32" y1="32" x2="32" y2="-32"/>
</g>
<!-- N marker (cardinal) -->
<path d="M 0 -42 L -4 -52 L 4 -52 Z" fill="#00D9FF"/>
<!-- Water reflection band -->
<ellipse cx="0" cy="13" rx="34" ry="3" fill="url(#m_water)"/>
<!-- ====== VESSEL SILHOUETTE (side view, yacht motor planeo) ====== -->
<g filter="url(#m_softGlow)">
<!-- Hull (planing V with chine, transom at right) -->
<!-- Bow at left, stern at right -->
<path d="
M -32 6
C -28 -1, -22 -5, -14 -6
L 22 -6
L 30 -2
L 30 6
L 24 11
L -26 11
Z"
fill="url(#m_hull)" stroke="#04111F" stroke-width="0.8" stroke-linejoin="round"/>
<!-- Chine line (highlight) -->
<path d="M -28 5 L 28 5" stroke="#00D9FF" stroke-width="0.4" opacity="0.5"/>
<!-- Superstructure (main deck cabin) -->
<path d="
M -10 -6
L -4 -14
L 14 -14
L 18 -6
Z"
fill="url(#m_super)" stroke="#04111F" stroke-width="0.7" stroke-linejoin="round"/>
<!-- Flybridge (upper deck) -->
<path d="
M -2 -14
L 0 -19
L 10 -19
L 12 -14
Z"
fill="url(#m_super)" stroke="#04111F" stroke-width="0.6" stroke-linejoin="round"/>
<!-- Windscreen (cyan glow line on main cabin) -->
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none" opacity="0.95"/>
<!-- Mast / radar arch -->
<line x1="5" y1="-19" x2="5" y2="-26" stroke="#7C8B9F" stroke-width="0.8"/>
<circle cx="5" cy="-26.5" r="1.2" fill="#00D9FF"/>
<!-- Bow waterline spray -->
<path d="M -34 8 Q -36 11 -32 12" stroke="#00D9FF" stroke-width="0.7" fill="none" opacity="0.65"/>
<!-- Stern transom detail -->
<line x1="28" y1="-4" x2="28" y2="9" stroke="#04111F" stroke-width="0.4" opacity="0.5"/>
</g> </g>
<path d="M -25 5 Q -21 -3 0 -3 Q 21 -3 25 5 L 18 13 L -18 13 Z"
fill="url(#m_hull)" stroke="#04111F" stroke-width="0.8"/>
<path d="M -1 -3 L -1 -22 L 16 -3 Z" fill="#00D9FF"/>
<circle r="3" fill="#FFFFFF"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+21 -12
View File
@@ -1,18 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" width="320" height="80" role="img" aria-label="VMS-Sailor monochrome"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 96" width="360" height="96" role="img" aria-label="VMS-Sailor monochrome">
<g transform="translate(40,40)"> <g transform="translate(48,48)" fill="currentColor">
<circle r="28" fill="none" stroke="currentColor" stroke-width="2"/> <circle r="38" fill="none" stroke="currentColor" stroke-width="2.2"/>
<g stroke="currentColor" stroke-width="1" stroke-linecap="round"> <g stroke="currentColor" stroke-width="0.6" opacity="0.5">
<line x1="0" y1="-26" x2="0" y2="26"/> <line x1="-26" y1="-26" x2="26" y2="26"/>
<line x1="-26" y1="0" x2="26" y2="0"/> <line x1="-26" y1="26" x2="26" y2="-26"/>
<line x1="-18" y1="-18" x2="18" y2="18" opacity="0.5"/> </g>
<line x1="-18" y1="18" x2="18" y2="-18" opacity="0.5"/> <g stroke="currentColor" stroke-width="0.8">
<line x1="0" y1="-38" x2="0" y2="-34"/>
<line x1="0" y1="38" x2="0" y2="34"/>
<line x1="-38" y1="0" x2="-34" y2="0"/>
<line x1="38" y1="0" x2="34" y2="0"/>
</g>
<path d="M 0 -34 L -3 -42 L 3 -42 Z" fill="currentColor"/>
<g transform="scale(0.85)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z" fill="currentColor"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="currentColor"/>
<path d="M -2 -14 L 0 -19 L 10 -19 L 12 -14 Z" fill="currentColor"/>
<line x1="5" y1="-19" x2="5" y2="-25" stroke="currentColor" stroke-width="0.7"/>
</g> </g>
<path d="M -19 4 Q -16 -2 0 -2 Q 16 -2 19 4 L 14 10 L -14 10 Z" fill="currentColor"/>
<path d="M -1 -2 L -1 -16 L 12 -2 Z" fill="currentColor"/>
</g> </g>
<g font-family="'Space Grotesk', 'Inter', system-ui, sans-serif" fill="currentColor"> <g font-family="'Space Grotesk', 'Inter', system-ui, sans-serif" fill="currentColor">
<text x="88" y="44" font-size="28" font-weight="700" letter-spacing="-0.5">VMS · Sailor</text> <text x="110" y="54" font-size="32" font-weight="700" letter-spacing="-1">VMS · Sailor</text>
<text x="88" y="62" font-size="10" font-weight="500" letter-spacing="3">VESSEL · MANAGEMENT · SYSTEM</text> <text x="110" y="74" font-size="10" font-weight="600" letter-spacing="3.5">VESSEL · MANAGEMENT · SYSTEM</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+67 -34
View File
@@ -1,52 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" width="320" height="80" role="img" aria-label="VMS-Sailor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 96" width="360" height="96" role="img" aria-label="VMS-Sailor">
<defs> <defs>
<linearGradient id="cyanGrad" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="L_cyan" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#00D9FF"/> <stop offset="0%" stop-color="#00D9FF"/>
<stop offset="55%" stop-color="#5BC0EB"/> <stop offset="60%" stop-color="#5BC0EB"/>
<stop offset="100%" stop-color="#1B7FB5"/> <stop offset="100%" stop-color="#1B7FB5"/>
</linearGradient> </linearGradient>
<linearGradient id="hullGrad" x1="0%" y1="0%" x2="0%" y2="100%"> <linearGradient id="L_hull" x1="0%" y1="0%" x2="0%" y2="1">
<stop offset="0%" stop-color="#E6EAF0"/> <stop offset="0%" stop-color="#F2F5F9"/>
<stop offset="55%" stop-color="#C7D0DD"/>
<stop offset="100%" stop-color="#7C8B9F"/> <stop offset="100%" stop-color="#7C8B9F"/>
</linearGradient> </linearGradient>
<radialGradient id="glow" cx="50%" cy="50%" r="60%"> <linearGradient id="L_super" x1="0%" y1="0%" x2="0%" y2="1">
<stop offset="0%" stop-color="#00D9FF" stop-opacity="0.55"/> <stop offset="0%" stop-color="#FFFFFF"/>
<stop offset="100%" stop-color="#B8C2D1"/>
</linearGradient>
<radialGradient id="L_glow" cx="50%" cy="50%" r="55%">
<stop offset="0%" stop-color="#00D9FF" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#00D9FF" stop-opacity="0"/> <stop offset="100%" stop-color="#00D9FF" stop-opacity="0"/>
</radialGradient> </radialGradient>
<filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%"> <filter id="L_softGlow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="2.5" result="blur"/> <feGaussianBlur stdDeviation="1.5" result="b"/>
<feMerge> <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter> </filter>
</defs> </defs>
<!-- Compass / hull mark --> <!-- ===== Compass + Yacht ===== -->
<g transform="translate(40,40)" filter="url(#softGlow)"> <g transform="translate(48,48)">
<circle r="34" fill="url(#glow)" /> <circle r="46" fill="url(#L_glow)"/>
<circle r="28" fill="none" stroke="url(#cyanGrad)" stroke-width="2.2"/> <circle r="38" fill="none" stroke="url(#L_cyan)" stroke-width="2.4"/>
<!-- 8-point compass star -->
<g stroke="#E6EAF0" stroke-width="1.2" stroke-linecap="round"> <g stroke="#E6EAF0" stroke-width="0.6" opacity="0.4">
<line x1="0" y1="-26" x2="0" y2="26"/> <line x1="-26" y1="-26" x2="26" y2="26"/>
<line x1="-26" y1="0" x2="26" y2="0"/> <line x1="-26" y1="26" x2="26" y2="-26"/>
<line x1="-18" y1="-18" x2="18" y2="18" opacity="0.4"/> </g>
<line x1="-18" y1="18" x2="18" y2="-18" opacity="0.4"/> <g stroke="#5BC0EB" stroke-width="0.9" opacity="0.7">
<line x1="0" y1="-38" x2="0" y2="-34"/>
<line x1="0" y1="38" x2="0" y2="34"/>
<line x1="-38" y1="0" x2="-34" y2="0"/>
<line x1="38" y1="0" x2="34" y2="0"/>
</g>
<path d="M 0 -34 L -3 -42 L 3 -42 Z" fill="#00D9FF"/>
<!-- Yacht silhouette (side view) -->
<g filter="url(#L_softGlow)" transform="scale(0.85)">
<path d="
M -32 6
C -28 -1, -22 -5, -14 -6
L 22 -6
L 30 -2
L 30 6
L 24 11
L -26 11
Z"
fill="url(#L_hull)" stroke="#04111F" stroke-width="0.7" stroke-linejoin="round"/>
<path d="
M -10 -6
L -4 -14
L 14 -14
L 18 -6
Z"
fill="url(#L_super)" stroke="#04111F" stroke-width="0.6"/>
<path d="
M -2 -14
L 0 -19
L 10 -19
L 12 -14
Z"
fill="url(#L_super)" stroke="#04111F" stroke-width="0.5"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.8" fill="none"/>
<line x1="5" y1="-19" x2="5" y2="-25" stroke="#7C8B9F" stroke-width="0.7"/>
<circle cx="5" cy="-25" r="1" fill="#00D9FF"/>
</g> </g>
<!-- Stylized boat silhouette -->
<path d="M -19 4 Q -16 -2 0 -2 Q 16 -2 19 4 L 14 10 L -14 10 Z"
fill="url(#hullGrad)" stroke="#04111F" stroke-width="0.6"/>
<path d="M -1 -2 L -1 -16 L 12 -2 Z" fill="#00D9FF" opacity="0.95"/>
<circle r="2.2" fill="#FFFFFF" cx="0" cy="0"/>
</g> </g>
<!-- Wordmark --> <!-- ===== Wordmark ===== -->
<g font-family="'Space Grotesk', 'Inter', system-ui, sans-serif" fill="#F2F5F9"> <g font-family="'Space Grotesk', 'Inter', system-ui, sans-serif">
<text x="88" y="44" font-size="28" font-weight="700" letter-spacing="-0.5">VMS</text> <text x="110" y="54" font-size="32" font-weight="700" letter-spacing="-1" fill="#F2F5F9">VMS</text>
<text x="146" y="44" font-size="28" font-weight="300" letter-spacing="-0.3" fill="#00D9FF">·</text> <text x="170" y="54" font-size="32" font-weight="300" fill="#00D9FF">·</text>
<text x="158" y="44" font-size="28" font-weight="400" letter-spacing="-0.3">Sailor</text> <text x="183" y="54" font-size="32" font-weight="400" letter-spacing="-0.3" fill="#F2F5F9">Sailor</text>
<text x="88" y="62" font-size="10" font-weight="500" letter-spacing="3" fill="#7C8B9F"> <text x="110" y="74" font-size="10" font-weight="600" letter-spacing="3.5" fill="#7C8B9F">
VESSEL · MANAGEMENT · SYSTEM VESSEL · MANAGEMENT · SYSTEM
</text> </text>
</g> </g>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+11 -6
View File
@@ -357,13 +357,18 @@
left: 0; right: 0; bottom: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(180deg, #1B7FB5 0%, #00D9FF 100%); background: linear-gradient(180deg, #1B7FB5 0%, #00D9FF 100%);
} }
/* Surface highlight per fill color via currentColor on the fill */
.tank-fill { color: rgba(0,217,255,0.55); }
.tank-fill.warn { background: linear-gradient(180deg, #C0760F 0%, #FFB020 100%); color: rgba(255,176,32,0.55); }
.tank-fill.water { background: linear-gradient(180deg, #007F4E 0%, #00E08A 100%); color: rgba(0,224,138,0.55); }
.tank-fill.black { background: linear-gradient(180deg, #5A6B7F 0%, #94A3B8 100%); color: rgba(148,162,177,0.55); }
.tank-fill::before { .tank-fill::before {
content: ""; position: absolute; content: ""; position: absolute;
top: -3px; left: 0; right: 0; height: 6px; top: 0; left: 0; right: 0; height: 4px;
background: rgba(0,217,255,0.5); background: currentColor;
filter: blur(2px); box-shadow: 0 0 6px currentColor;
pointer-events: none;
} }
.tank-fill.warn { background: linear-gradient(180deg, #C0760F 0%, #FFB020 100%); }
.tank-label { font-family: var(--f-mono); font-size: 11px; color: var(--c-fog); } .tank-label { font-family: var(--f-mono); font-size: 11px; color: var(--c-fog); }
.tank-pct { .tank-pct {
font-family: var(--f-mono); font-family: var(--f-mono);
@@ -684,7 +689,7 @@
</div> </div>
<div class="tank"> <div class="tank">
<div class="tank-shell"> <div class="tank-shell">
<div class="tank-fill" style="height: 91%; background: linear-gradient(180deg, #007F4E 0%, #00E08A 100%);"></div> <div class="tank-fill water" style="height: 91%;"></div>
</div> </div>
<div class="tank-pct">91%</div> <div class="tank-pct">91%</div>
<div class="tank-label">WATER</div> <div class="tank-label">WATER</div>
@@ -698,7 +703,7 @@
</div> </div>
<div class="tank"> <div class="tank">
<div class="tank-shell"> <div class="tank-shell">
<div class="tank-fill" style="height: 28%; background: linear-gradient(180deg, #5A6B7F 0%, #94A3B8 100%);"></div> <div class="tank-fill black" style="height: 28%;"></div>
</div> </div>
<div class="tank-pct">28%</div> <div class="tank-pct">28%</div>
<div class="tank-label">BLACK</div> <div class="tank-label">BLACK</div>
+328 -5
View File
@@ -13,6 +13,7 @@ from datetime import datetime
from typing import Any from typing import Any
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, Response
from vmssailor.runtime.server.runtime_app import RuntimeApp from vmssailor.runtime.server.runtime_app import RuntimeApp
from vmssailor.version import __version__ from vmssailor.version import __version__
@@ -39,6 +40,18 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
) )
app.state.runtime = runtime app.state.runtime = runtime
# ----- Root + favicon (UI amigable de bienvenida) -----------------
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def root() -> str:
return _RENDER_ROOT_HTML(runtime)
@app.get("/favicon.ico", include_in_schema=False)
def favicon() -> Response:
# SVG inline para evitar dependencias en disco
svg = _FAVICON_SVG.encode("utf-8")
return Response(content=svg, media_type="image/svg+xml")
# ----- Health ------------------------------------------------------ # ----- Health ------------------------------------------------------
@app.get("/health") @app.get("/health")
@@ -124,8 +137,11 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
if tag_id not in runtime.tag_store: if tag_id not in runtime.tag_store:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.") raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.")
since_dt = datetime.fromisoformat(since) if since else None try:
until_dt = datetime.fromisoformat(until) if until else None since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
except ValueError:
raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit) return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit)
# ----- Alarms ------------------------------------------------------ # ----- Alarms ------------------------------------------------------
@@ -156,9 +172,15 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
from vmssailor.runtime.server.logbook import LogEntryKind from vmssailor.runtime.server.logbook import LogEntryKind
kind_enum = LogEntryKind(kind) if kind else None try:
since_dt = datetime.fromisoformat(since) if since else None kind_enum = LogEntryKind(kind) if kind else None
until_dt = datetime.fromisoformat(until) if until else None except ValueError:
raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}")
try:
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
except ValueError:
raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
entries = runtime.logbook.query( entries = runtime.logbook.query(
kind=kind_enum, since=since_dt, until=until_dt, limit=limit kind=kind_enum, since=since_dt, until=until_dt, limit=limit
) )
@@ -232,3 +254,304 @@ def create_app(runtime: RuntimeApp) -> FastAPI:
await ws.close() await ws.close()
return app return app
# ===========================================================================
# Root HTML (UI amigable de bienvenida con dashboard vivo)
# ===========================================================================
_FAVICON_SVG = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#04111F"/>
<circle cx="32" cy="32" r="22" fill="none" stroke="#00D9FF" stroke-width="2.2"/>
<path d="M 32 10 L 30 6 L 34 6 Z" fill="#00D9FF"/>
<g transform="translate(32,33) scale(0.55)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z" fill="#E6EAF0"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
</g>
</svg>"""
def _RENDER_ROOT_HTML(runtime: RuntimeApp) -> str:
project = runtime.project
tag_stats = runtime.tag_store.stats()
active_alarms = len(runtime.alarm_engine.active_alarms())
return f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VMS-Sailor Runtime · {project.name}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<style>
:root {{
--abyss: #04111F; --midnight: #0A1A2E; --steel: #1A2B42; --iron: #2C3E5C;
--fog: #7C8B9F; --sand: #E6EAF0; --foam: #F2F5F9;
--cyan: #00D9FF; --cyan-deep: #1B7FB5; --horizon: #5BC0EB;
--ok: #00E08A; --warn: #FFB020; --emergency: #FF3B47;
}}
*, *::before, *::after {{ box-sizing: border-box; }}
html, body {{
margin: 0; padding: 0; min-height: 100vh;
background: linear-gradient(135deg, #04111F 0%, #0A1A2E 60%, #1A2B42 100%);
color: var(--sand);
font-family: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
font-size: 14px;
}}
.container {{ max-width: 1280px; margin: 0 auto; padding: 32px; }}
.hero {{
display: flex; align-items: center; gap: 24px;
padding: 24px 32px;
background: linear-gradient(135deg, rgba(0,217,255,0.10), rgba(0,217,255,0.02));
border: 1px solid rgba(0,217,255,0.25);
border-radius: 16px;
margin-bottom: 24px;
}}
.hero-logo {{
width: 84px; height: 84px;
display: flex; align-items: center; justify-content: center;
filter: drop-shadow(0 8px 24px rgba(0,217,255,0.4));
flex-shrink: 0;
}}
.hero h1 {{
margin: 0;
font-size: 32px; font-weight: 700; letter-spacing: -1px;
color: var(--foam);
}}
.hero h1 .accent {{ color: var(--cyan); font-weight: 300; }}
.hero .subtitle {{
color: var(--horizon); margin-top: 4px;
font-size: 13px; letter-spacing: 2px; text-transform: uppercase;
}}
.hero .vessel-info {{
color: var(--sand); margin-top: 8px;
font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px;
}}
.hero .pill {{
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; margin-left: auto;
background: rgba(0,224,138,0.15); border: 1px solid rgba(0,224,138,0.4);
border-radius: 999px; color: var(--ok);
font-size: 12px; font-weight: 700; letter-spacing: 0.5px;
}}
.pill .dot {{
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok); box-shadow: 0 0 10px var(--ok);
animation: pulse 2s ease-in-out infinite;
}}
@keyframes pulse {{ 50% {{ opacity: 0.5; transform: scale(1.4); }} }}
.stats-row {{
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 24px;
}}
.stat {{
padding: 16px 18px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 10px;
}}
.stat .label {{
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
color: var(--fog); font-weight: 700;
}}
.stat .value {{
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 28px; font-weight: 600; color: var(--foam);
margin-top: 6px; letter-spacing: -0.5px;
}}
.stat .value.alarm {{ color: var(--emergency); }}
.stat .sub {{ font-size: 11px; color: var(--fog); margin-top: 2px; }}
h2 {{
font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
color: var(--fog); font-weight: 700;
margin: 24px 0 12px 0;
}}
.actions {{
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px; margin-bottom: 24px;
}}
.action {{
display: block; text-decoration: none;
padding: 16px 18px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 10px;
transition: all 160ms;
}}
.action:hover {{
border-color: var(--cyan); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 16px rgba(0,217,255,0.3);
}}
.action .title {{
color: var(--foam); font-weight: 600; font-size: 14px;
display: flex; align-items: center; gap: 8px;
}}
.action .title::after {{ content: ""; color: var(--cyan); margin-left: auto; }}
.action .desc {{
color: var(--fog); font-size: 12px; margin-top: 4px;
}}
.action .url {{
color: var(--cyan); font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11px; margin-top: 6px;
}}
#tags-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}}
.tag-tile {{
padding: 10px 12px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 8px;
font-family: "JetBrains Mono", Consolas, monospace;
}}
.tag-tile .tid {{ color: var(--cyan); font-size: 10px; }}
.tag-tile .val {{
color: var(--foam); font-size: 18px; font-weight: 600;
margin-top: 4px;
}}
.tag-tile .meta {{
color: var(--fog); font-size: 9px; margin-top: 2px;
}}
footer {{
margin-top: 32px; padding-top: 16px;
border-top: 1px solid var(--steel);
color: var(--fog); font-size: 11px;
font-family: "JetBrains Mono", Consolas, monospace;
display: flex; justify-content: space-between;
}}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<div class="hero-logo">
<svg viewBox="0 0 120 120" width="84" height="84">
<circle cx="60" cy="60" r="46" fill="none" stroke="#00D9FF" stroke-width="3"/>
<path d="M 60 18 L 56 8 L 64 8 Z" fill="#00D9FF"/>
<g transform="translate(60,60)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z"
fill="#E6EAF0" stroke="#04111F" stroke-width="0.8"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.6"/>
<path d="M -2 -14 L 0 -19 L 10 -19 L 12 -14 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.5"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
</g>
</svg>
</div>
<div>
<h1>VMS<span class="accent"> · </span>Sailor</h1>
<div class="subtitle">Vessel · Management · System</div>
<div class="vessel-info">
{project.vessel.name} · {project.vessel.length_overall_m:.1f} m × {project.vessel.beam_max_m:.1f} m ·
proyecto: {project.id}
</div>
</div>
<span class="pill"><span class="dot"></span>RUNTIME ACTIVO</span>
</div>
<div class="stats-row">
<div class="stat">
<div class="label">Tags activos</div>
<div class="value">{tag_stats['total_tags']}</div>
<div class="sub">{tag_stats['by_quality'].get('good', 0)} good · simulator vivo</div>
</div>
<div class="stat">
<div class="label">Alarmas activas</div>
<div class="value {'alarm' if active_alarms > 0 else ''}">{active_alarms}</div>
<div class="sub">{'requieren ACK' if active_alarms else 'sin alarmas'}</div>
</div>
<div class="stat">
<div class="label">Sistemas</div>
<div class="value">{len(project.systems_enabled)}</div>
<div class="sub">habilitados</div>
</div>
<div class="stat">
<div class="label">Equipos</div>
<div class="value">{len(project.equipment)}</div>
<div class="sub">en el proyecto</div>
</div>
</div>
<h2>API Endpoints</h2>
<div class="actions">
<a class="action" href="/docs">
<div class="title">Documentación interactiva (Swagger UI)</div>
<div class="desc">Probar todos los endpoints de la API en el navegador.</div>
<div class="url">/docs</div>
</a>
<a class="action" href="/health">
<div class="title">Health</div>
<div class="desc">Estado del runtime, tag store, historian, alarmas.</div>
<div class="url">/health</div>
</a>
<a class="action" href="/project">
<div class="title">Project</div>
<div class="desc">Info del proyecto cargado (buque, sistemas, stats).</div>
<div class="url">/project</div>
</a>
<a class="action" href="/tags">
<div class="title">Tags (snapshot)</div>
<div class="desc">Lista completa de tags con valores actuales del simulator.</div>
<div class="url">/tags</div>
</a>
<a class="action" href="/alarms">
<div class="title">Alarms</div>
<div class="desc">Histórico de eventos de alarma desde el arranque.</div>
<div class="url">/alarms</div>
</a>
<a class="action" href="/logbook">
<div class="title">Log Book</div>
<div class="desc">Bitácora naval con arranques de motor, snapshots, alarmas.</div>
<div class="url">/logbook</div>
</a>
</div>
<h2>Tags en vivo (actualizando cada 1 s)</h2>
<div id="tags-grid">Cargando...</div>
<footer>
<span>VMS-Sailor Runtime · v{__version__}</span>
<span>Para conectar el cliente desktop: <code>uv run python runtime_client_main.py</code></span>
</footer>
</div>
<script>
async function refreshTags() {{
try {{
const r = await fetch('/tags');
const data = await r.json();
const grid = document.getElementById('tags-grid');
grid.innerHTML = data.map(t => {{
const val = typeof t.value === 'number' ? t.value.toFixed(2) : String(t.value ?? '');
const unit = t.unit_si && t.unit_si !== 'none' ? ' ' + t.unit_si : '';
const q = t.quality || '';
const color = q === 'good' ? '#00E08A' : '#FFB020';
return `<div class="tag-tile">
<div class="tid">${{t.id}}</div>
<div class="val">${{val}}${{unit}}</div>
<div class="meta" style="color:${{color}}">q=${{q}}</div>
</div>`;
}}).join('');
}} catch (e) {{
const msg = document.createTextNode('Error de conexión: ' + String(e));
const div = document.createElement('div');
div.style.color = 'var(--emergency)';
div.appendChild(msg);
const grid = document.getElementById('tags-grid');
grid.innerHTML = '';
grid.appendChild(div);
}}
}}
refreshTags();
setInterval(refreshTags, 1000);
</script>
</body>
</html>"""
+37 -3
View File
@@ -66,6 +66,7 @@ class MimicEditor(QWidget):
# Almacén in-memory de mímicos por sistema: # Almacén in-memory de mímicos por sistema:
# { system_id_value : [SymbolSpec, ...] } # { system_id_value : [SymbolSpec, ...] }
self._mimics: dict[str, list[SymbolSpec]] = {} self._mimics: dict[str, list[SymbolSpec]] = {}
self._empty_state_active: bool = True
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 12, 12) outer.setContentsMargins(12, 12, 12, 12)
@@ -227,15 +228,46 @@ class MimicEditor(QWidget):
self.mimicChanged.emit() self.mimicChanged.emit()
def _on_palette_double_click(self, item: QListWidgetItem) -> None: def _on_palette_double_click(self, item: QListWidgetItem) -> None:
"""Agrega un símbolo nuevo SIN reconstruir la escena.
Si ya hay símbolos arrastrados por el usuario, conservan posición.
El nuevo se coloca en el siguiente slot libre de una grilla 6 × N.
"""
sys = self._current_system() sys = self._current_system()
if sys is None: if sys is None:
return return
kind = item.data(Qt.ItemDataRole.UserRole) kind = item.data(Qt.ItemDataRole.UserRole)
new_spec = SymbolSpec(kind=kind, x=100, y=100, label="") existing = self._mimics.setdefault(sys, [])
self._mimics.setdefault(sys, []).append(new_spec) x, y = self._next_free_position(existing)
self._render_current() new_spec = SymbolSpec(kind=kind, x=x, y=y, label="")
existing.append(new_spec)
if self._empty_state_active:
# En empty state hay un label central; render completo para limpiarlo
self._render_current()
else:
# Solo agrega el nuevo item — preserva posiciones arrastradas
new_item = make_symbol(new_spec)
self._scene.addItem(new_item)
new_item.setSelected(True)
self._symbol_count.setText(f"{len(existing)} símbolos")
self.mimicChanged.emit() self.mimicChanged.emit()
def _next_free_position(self, specs: list[SymbolSpec]) -> tuple[float, float]:
"""Devuelve (x, y) en una grilla 6 × N evitando solapamientos."""
col_w, row_h = 110, 90
cols = 6
used = set()
for s in specs:
col = int(round((s.x - 40) / col_w))
row = int(round((s.y - 40) / row_h))
used.add((col, row))
for row in range(30):
for col in range(cols):
if (col, row) not in used:
return 40.0 + col * col_w, 40.0 + row * row_h
return 40.0, 40.0
def _render_current(self) -> None: def _render_current(self) -> None:
sys = self._current_system() sys = self._current_system()
self._scene.clear() self._scene.clear()
@@ -250,12 +282,14 @@ class MimicEditor(QWidget):
) )
self._symbol_count.setText("0 símbolos") self._symbol_count.setText("0 símbolos")
return return
self._empty_state_active = False
self._draw_grid() self._draw_grid()
for spec in specs: for spec in specs:
self._scene.addItem(make_symbol(spec)) self._scene.addItem(make_symbol(spec))
self._symbol_count.setText(f"{len(specs)} símbolos") self._symbol_count.setText(f"{len(specs)} símbolos")
def _draw_empty_state(self, msg: str) -> None: def _draw_empty_state(self, msg: str) -> None:
self._empty_state_active = True
self._draw_grid() self._draw_grid()
text = self._scene.addText(msg, ui_font(11)) text = self._scene.addText(msg, ui_font(11))
text.setDefaultTextColor(QColor(C_FOG)) text.setDefaultTextColor(QColor(C_FOG))
+17 -2
View File
@@ -73,22 +73,37 @@ class SymbolSpec:
class _BaseSymbol(QGraphicsItemGroup): class _BaseSymbol(QGraphicsItemGroup):
"""Símbolo base. Subclases dibujan en `_build()`.""" """Símbolo base. Subclases dibujan en `_build()`.
Cuando el usuario arrastra el símbolo, `itemChange` propaga la nueva
posición al `SymbolSpec` para que sobreviva a re-renders del editor.
"""
KIND: ClassVar[str] = "base" KIND: ClassVar[str] = "base"
def __init__(self, spec: SymbolSpec) -> None: def __init__(self, spec: SymbolSpec) -> None:
super().__init__() super().__init__()
# NOTA: asignar self.spec ANTES de setPos para que itemChange tenga
# acceso al spec cuando Qt dispare ItemPositionHasChanged durante init.
self.spec = spec
self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsMovable, True)
self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True)
self.setPos(spec.x, spec.y) self.setPos(spec.x, spec.y)
self.setRotation(spec.rotation_deg) self.setRotation(spec.rotation_deg)
self.spec = spec
self._build() self._build()
if spec.label: if spec.label:
self._add_label(spec.label) self._add_label(spec.label)
def itemChange(self, change, value): # type: ignore[override]
"""Propaga la posición arrastrada al SymbolSpec en vivo."""
if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
spec = getattr(self, "spec", None)
if spec is not None:
spec.x = float(self.pos().x())
spec.y = float(self.pos().y())
return super().itemChange(change, value)
def _build(self) -> None: def _build(self) -> None:
raise NotImplementedError raise NotImplementedError
+18 -4
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSplitter, QSplitter,
QStackedWidget,
QStatusBar, QStatusBar,
QTabWidget, QTabWidget,
QToolBar, QToolBar,
@@ -42,6 +43,7 @@ from vmssailor.studio.theme import (
) )
from vmssailor.studio.widgets.system_sidebar import SystemSidebar from vmssailor.studio.widgets.system_sidebar import SystemSidebar
from vmssailor.studio.widgets.vessel_canvas import VesselCanvas from vmssailor.studio.widgets.vessel_canvas import VesselCanvas
from vmssailor.studio.widgets.welcome_screen import WelcomeScreen
from vmssailor.studio.wizard.wizard import VesselWizard from vmssailor.studio.wizard.wizard import VesselWizard
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -119,7 +121,10 @@ class MainWindow(QMainWindow):
self._topbar = bar self._topbar = bar
def _build_central(self) -> None: def _build_central(self) -> None:
# Splitter horizontal: sidebar | canvas # Welcome screen (visible al arrancar sin proyecto)
self._welcome = WelcomeScreen()
# Workspace splitter (visible cuando hay proyecto cargado)
self._splitter = QSplitter(Qt.Horizontal) self._splitter = QSplitter(Qt.Horizontal)
self._splitter.setHandleWidth(1) self._splitter.setHandleWidth(1)
self._splitter.setChildrenCollapsible(False) self._splitter.setChildrenCollapsible(False)
@@ -129,7 +134,6 @@ class MainWindow(QMainWindow):
self._sidebar.setMinimumWidth(260) self._sidebar.setMinimumWidth(260)
self._sidebar.setMaximumWidth(380) self._sidebar.setMaximumWidth(380)
# Right pane: vertical splitter with canvas on top + editor tabs below
self._canvas = VesselCanvas() self._canvas = VesselCanvas()
self._equipment_editor = EquipmentEditor() self._equipment_editor = EquipmentEditor()
self._mimic_editor = MimicEditor() self._mimic_editor = MimicEditor()
@@ -153,13 +157,18 @@ class MainWindow(QMainWindow):
self._splitter.addWidget(right_splitter) self._splitter.addWidget(right_splitter)
self._splitter.setSizes([280, 1160]) self._splitter.setSizes([280, 1160])
# Compose central widget: topbar (top) + splitter (rest) # StackedWidget conmuta entre welcome y workspace
self._main_stack = QStackedWidget()
self._main_stack.addWidget(self._welcome) # index 0
self._main_stack.addWidget(self._splitter) # index 1
# Compose central widget: topbar (top) + stack (rest)
wrapper = QWidget() wrapper = QWidget()
outer = QVBoxLayout(wrapper) outer = QVBoxLayout(wrapper)
outer.setContentsMargins(0, 0, 0, 0) outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0) outer.setSpacing(0)
outer.addWidget(self._topbar) outer.addWidget(self._topbar)
outer.addWidget(self._splitter, 1) outer.addWidget(self._main_stack, 1)
self.setCentralWidget(wrapper) self.setCentralWidget(wrapper)
def _build_statusbar(self) -> None: def _build_statusbar(self) -> None:
@@ -254,6 +263,9 @@ class MainWindow(QMainWindow):
self._equipment_editor.projectMutated.connect(self._on_project_mutated) self._equipment_editor.projectMutated.connect(self._on_project_mutated)
self._tag_editor.projectMutated.connect(self._on_project_mutated) self._tag_editor.projectMutated.connect(self._on_project_mutated)
self._alarm_editor.projectMutated.connect(self._on_project_mutated) self._alarm_editor.projectMutated.connect(self._on_project_mutated)
# Welcome screen CTAs disparan las mismas acciones que el menú
self._welcome.newProjectRequested.connect(self.on_new_wizard)
self._welcome.openProjectRequested.connect(self.on_open)
# ----- Slots -------------------------------------------------------- # ----- Slots --------------------------------------------------------
@@ -360,6 +372,8 @@ class MainWindow(QMainWindow):
self._btn_save_top.setEnabled(has_project) self._btn_save_top.setEnabled(has_project)
self._btn_validate.setEnabled(has_project) self._btn_validate.setEnabled(has_project)
self._btn_compile.setEnabled(has_project) self._btn_compile.setEnabled(has_project)
# Conmuta entre welcome screen (0) y workspace (1)
self._main_stack.setCurrentIndex(1 if has_project else 0)
self._update_window_title() self._update_window_title()
self._update_stats() self._update_stats()
if project is not None: if project is not None:
+312 -48
View File
@@ -214,37 +214,106 @@ class VesselCanvas(QWidget):
self._scene.addLine(0, rect.top(), 0, rect.bottom(), cl_pen) self._scene.addLine(0, rect.top(), 0, rect.bottom(), cl_pen)
def _draw_vessel_plan(self, vessel) -> None: def _draw_vessel_plan(self, vessel) -> None:
"""Silueta del buque en vista de planta.
Eje X de escena = x_pp (popa en 0, proa positiva).
Eje Y de escena = -y_cl (estribor en y negativo, babor en y positivo).
Forma realista de yacht motor planeo:
- Popa cuadrada (transom)
- Sección paralela media (max beam de ~25% a ~70% LOA)
- Proa puntiaguda con curva tipo bow flare
- Casco simétrico babor/estribor
"""
from PySide6.QtGui import QPainterPath
L = vessel.length_overall_m * self._px_per_m L = vessel.length_overall_m * self._px_per_m
B = vessel.beam_max_m * self._px_per_m B = vessel.beam_max_m * self._px_per_m
b2 = B / 2 b2 = B / 2
poly = QPolygonF( # ----- Casco (hull outline) usando bezier para curvas suaves -----
[ path = QPainterPath()
# Stern (popa) — square-ish # Stern transom (esquina popa estribor)
QPointF(0, -b2 * 0.85), path.moveTo(0, -b2 * 0.90)
QPointF(0, b2 * 0.85), # Quarter aft (popa-estribor) — beam crece rápido
# Mid-stern out to full beam path.cubicTo(L * 0.05, -b2 * 0.96, L * 0.12, -b2, L * 0.22, -b2)
QPointF(L * 0.18, b2), # Sección paralela estribor
# Parallel mid-body path.lineTo(L * 0.62, -b2)
QPointF(L * 0.70, b2), # Forward shoulder (transición a la proa)
# Bow taper path.cubicTo(L * 0.78, -b2 * 0.95, L * 0.90, -b2 * 0.60, L * 0.97, -b2 * 0.18)
QPointF(L * 0.92, b2 * 0.55), # Punta de proa (curvatura aguda)
QPointF(L, 0), path.cubicTo(L * 1.005, -b2 * 0.05, L * 1.005, b2 * 0.05, L * 0.97, b2 * 0.18)
QPointF(L * 0.92, -b2 * 0.55), # Forward shoulder babor (espejo)
QPointF(L * 0.70, -b2), path.cubicTo(L * 0.90, b2 * 0.60, L * 0.78, b2 * 0.95, L * 0.62, b2)
QPointF(L * 0.18, -b2), # Sección paralela babor
] path.lineTo(L * 0.22, b2)
) # Quarter aft babor
path.cubicTo(L * 0.12, b2, L * 0.05, b2 * 0.96, 0, b2 * 0.90)
# Transom (popa rectangular)
path.lineTo(0, -b2 * 0.90)
path.closeSubpath()
# Casco gris claro con gradiente top-down (azul claro centro)
grad = QLinearGradient(0, -b2, 0, b2) grad = QLinearGradient(0, -b2, 0, b2)
grad.setColorAt(0.0, QColor(C_SAND)) grad.setColorAt(0.0, QColor("#D4DBE5"))
grad.setColorAt(0.5, QColor("#94A3B8")) grad.setColorAt(0.5, QColor("#E6EAF0"))
grad.setColorAt(1.0, QColor(C_FOG)) grad.setColorAt(1.0, QColor("#D4DBE5"))
brush = QBrush(grad)
pen = QPen(QColor(C_ABYSS)) pen = QPen(QColor(C_ABYSS))
pen.setWidthF(2) pen.setWidthF(2)
pen.setCosmetic(True) pen.setCosmetic(True)
self._scene.addPolygon(poly, pen, brush) self._scene.addPath(path, pen, QBrush(grad))
# ----- Superestructura (visible desde planta) -----
super_path = QPainterPath()
# Cabina principal (main deck), centrada longitudinalmente
sx0 = L * 0.30
sx1 = L * 0.72
sb = b2 * 0.55
super_path.moveTo(sx0, -sb)
super_path.lineTo(sx1 - 8, -sb)
super_path.cubicTo(sx1, -sb, sx1 + 6, -sb * 0.5, sx1 + 6, 0)
super_path.cubicTo(sx1 + 6, sb * 0.5, sx1, sb, sx1 - 8, sb)
super_path.lineTo(sx0, sb)
super_path.cubicTo(sx0 - 6, sb, sx0 - 8, sb * 0.5, sx0 - 8, 0)
super_path.cubicTo(sx0 - 8, -sb * 0.5, sx0 - 6, -sb, sx0, -sb)
super_path.closeSubpath()
super_brush = QBrush(QColor("#F2F5F9"))
super_pen = QPen(QColor(C_ABYSS))
super_pen.setWidthF(1.2)
super_pen.setCosmetic(True)
self._scene.addPath(super_path, super_pen, super_brush)
# ----- Flybridge superior (más estrecho) -----
fb_path = QPainterPath()
fbx0 = L * 0.42
fbx1 = L * 0.66
fbb = b2 * 0.38
fb_path.addRoundedRect(fbx0, -fbb, fbx1 - fbx0, 2 * fbb, 6, 6)
fb_pen = QPen(QColor(C_ABYSS))
fb_pen.setWidthF(1.0)
fb_pen.setCosmetic(True)
self._scene.addPath(fb_path, fb_pen, QBrush(QColor("#FFFFFF")))
# Mástil / radar arch
mast = self._scene.addEllipse(
L * 0.54 - 3, -3, 6, 6,
QPen(QColor(C_CYAN), 1.5), QBrush(QColor(C_CYAN))
)
_ = mast
# ----- Ventanas / windscreen (línea cyan brillante) -----
win_pen = QPen(QColor(C_CYAN))
win_pen.setWidthF(1.5)
win_pen.setCosmetic(True)
self._scene.addLine(sx1 - 6, -sb * 0.3, sx1 + 4, 0, win_pen)
self._scene.addLine(sx1 + 4, 0, sx1 - 6, sb * 0.3, win_pen)
# ----- Centerline (línea de crujía, referencia) -----
cl_pen = QPen(QColor(C_CYAN_DEEP))
cl_pen.setWidthF(0.7)
cl_pen.setStyle(Qt.DashLine)
cl_pen.setCosmetic(True)
self._scene.addLine(0, 0, L, 0, cl_pen)
def _draw_bulkhead(self, x_px: float, beam_m: float, name: str) -> None: def _draw_bulkhead(self, x_px: float, beam_m: float, name: str) -> None:
b2 = beam_m * self._px_per_m / 2 b2 = beam_m * self._px_per_m / 2
@@ -258,39 +327,234 @@ class VesselCanvas(QWidget):
text.setPos(x_px - text.boundingRect().width() / 2, b2 + 6) text.setPos(x_px - text.boundingRect().width() / 2, b2 + 6)
def _draw_equipment(self, eq) -> None: def _draw_equipment(self, eq) -> None:
"""Dibuja un equipo con silueta característica según su sistema."""
center = ship_to_scene(eq.location, self._px_per_m) 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 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) if sys_v == "main_engine":
self._draw_main_engine_icon(center, eq.tag_prefix)
elif sys_v == "genset":
self._draw_genset_icon(center, eq.tag_prefix)
elif sys_v in ("fuel_tanks", "water_tanks", "fuel", "potable_water", "grey_black_tanks"):
self._draw_tank_icon(center, eq.tag_prefix)
elif sys_v in ("bilge", "fw_cooling", "sw_cooling", "lube_oil", "hydraulic_oil"):
self._draw_pump_icon(center, eq.tag_prefix)
elif sys_v == "thruster":
self._draw_thruster_icon(center, eq.tag_prefix)
elif sys_v in ("hvac", "engine_vent", "refrigeration", "heating"):
self._draw_hvac_icon(center, eq.tag_prefix)
else:
self._draw_generic_icon(center, eq.tag_prefix, C_SAND)
# ----- Iconos de equipos --------------------------------------------
def _label_below(self, cx: float, cy: float, text: str, dy: float = 18.0) -> None:
t = self._scene.addText(text, ui_font(8))
t.setDefaultTextColor(QColor(C_FOAM))
rect = t.boundingRect()
t.setPos(cx - rect.width() / 2, cy + dy)
def _draw_main_engine_icon(self, center, prefix: str) -> None:
"""Motor diésel marino visto desde arriba: bloque + 2 turbos + alternador."""
from PySide6.QtCore import QRectF
from PySide6.QtGui import QPainterPath
cx, cy = center.x(), center.y()
w, h = 34.0, 18.0
# Block (cuerpo principal)
block_pen = QPen(QColor(C_ABYSS))
block_pen.setWidthF(1.5)
block_pen.setCosmetic(True)
grad = QLinearGradient(0, cy - h / 2, 0, cy + h / 2)
grad.setColorAt(0.0, QColor(C_CYAN))
grad.setColorAt(1.0, QColor(C_CYAN_DEEP))
block_path = QPainterPath()
block_path.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 3, 3)
self._scene.addPath(block_path, block_pen, QBrush(grad))
# Cilindros (V12: 6 puntos cada lado)
cyl_pen = QPen(QColor(C_ABYSS))
cyl_pen.setWidthF(0.8)
cyl_pen.setCosmetic(True)
cyl_brush = QBrush(QColor(C_FOAM))
for i in range(6):
x = cx - w / 2 + 3 + i * (w - 6) / 5
# cilindros estribor (arriba)
self._scene.addEllipse(x - 1.5, cy - h / 2 + 2, 3, 3, cyl_pen, cyl_brush)
# cilindros babor (abajo)
self._scene.addEllipse(x - 1.5, cy + h / 2 - 5, 3, 3, cyl_pen, cyl_brush)
# Turbos (popa = lado izq, x menor)
turbo_pen = QPen(QColor(C_ABYSS))
turbo_pen.setWidthF(1.0)
turbo_pen.setCosmetic(True)
self._scene.addEllipse( self._scene.addEllipse(
center.x() - 14, cx - w / 2 - 4, cy - 4, 5, 5, turbo_pen, QBrush(QColor("#94A3B8"))
center.y() - 14,
28,
28,
QPen(color, 1.2, Qt.SolidLine),
QBrush(QColor(color.red(), color.green(), color.blue(), 40)),
) )
# Dot # Alternador (proa, lado derecho)
self._scene.addRect(
cx + w / 2 - 1, cy - 3, 5, 6, turbo_pen, QBrush(QColor("#94A3B8"))
)
# Halo de selección sutil
halo_pen = QPen(QColor(C_CYAN))
halo_pen.setWidthF(0.6)
halo_pen.setStyle(Qt.DashLine)
halo_pen.setCosmetic(True)
halo_path = QPainterPath()
halo_path.addRoundedRect(cx - w / 2 - 6, cy - h / 2 - 4, w + 12, h + 8, 6, 6)
self._scene.addPath(halo_path, halo_pen, QBrush(Qt.NoBrush))
self._label_below(cx, cy, prefix, dy=h / 2 + 8)
def _draw_genset_icon(self, center, prefix: str) -> None:
"""Genset con cabina silenciosa: rectángulo grande + ventilación + chimenea."""
from PySide6.QtGui import QPainterPath
cx, cy = center.x(), center.y()
w, h = 28.0, 16.0
pen = QPen(QColor(C_ABYSS))
pen.setWidthF(1.5)
pen.setCosmetic(True)
grad = QLinearGradient(0, cy - h / 2, 0, cy + h / 2)
grad.setColorAt(0.0, QColor(C_WARN))
grad.setColorAt(1.0, QColor("#C0760F"))
gp = QPainterPath()
gp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 3, 3)
self._scene.addPath(gp, pen, QBrush(grad))
# Rejilla de ventilación (líneas verticales)
vent_pen = QPen(QColor(C_ABYSS))
vent_pen.setWidthF(0.6)
vent_pen.setCosmetic(True)
for i in range(5):
x = cx + 2 + i * 3
self._scene.addLine(x, cy - h / 2 + 3, x, cy + h / 2 - 3, vent_pen)
# Chimenea (escape) — círculo pequeño en proa
self._scene.addEllipse( self._scene.addEllipse(
center.x() - 6, cx - w / 2 + 3, cy - 2, 4, 4, pen, QBrush(QColor("#5A6B7F"))
center.y() - 6,
12,
12,
QPen(QColor(C_ABYSS), 1.5),
QBrush(color),
) )
# Label # Símbolo de generación (rayo) en el centro
text = self._scene.addText(eq.tag_prefix, ui_font(8)) from PySide6.QtGui import QPainterPath
text.setDefaultTextColor(QColor(C_FOAM)) bolt = QPainterPath()
text.setPos(center.x() - text.boundingRect().width() / 2, center.y() + 12) bolt.moveTo(cx - 2, cy - 4)
bolt.lineTo(cx + 1, cy - 1)
bolt.lineTo(cx - 1, cy - 1)
bolt.lineTo(cx + 2, cy + 4)
bolt_pen = QPen(QColor(C_FOAM))
bolt_pen.setWidthF(1.5)
bolt_pen.setCosmetic(True)
self._scene.addPath(bolt, bolt_pen, QBrush(Qt.NoBrush))
self._label_below(cx, cy, prefix, dy=h / 2 + 8)
def _draw_pump_icon(self, center, prefix: str) -> None:
"""Bomba centrífuga: círculo + voluta + descarga."""
cx, cy = center.x(), center.y()
r = 8.0
pen = QPen(QColor(C_ABYSS))
pen.setWidthF(1.5)
pen.setCosmetic(True)
self._scene.addEllipse(
cx - r, cy - r, 2 * r, 2 * r, pen, QBrush(QColor("#5BC0EB"))
)
# Aspas en cruz
aspas_pen = QPen(QColor(C_ABYSS))
aspas_pen.setWidthF(1.5)
aspas_pen.setCosmetic(True)
self._scene.addLine(cx - r * 0.7, cy, cx + r * 0.7, cy, aspas_pen)
self._scene.addLine(cx, cy - r * 0.7, cx, cy + r * 0.7, aspas_pen)
# Tubería de descarga (sale por arriba)
self._scene.addRect(cx - 2, cy - r - 4, 4, 4, pen, QBrush(QColor("#94A3B8")))
self._label_below(cx, cy, prefix, dy=r + 4)
def _draw_tank_icon(self, center, prefix: str) -> None:
"""Tanque visto desde planta: rectángulo redondeado con sub-divisiones."""
from PySide6.QtGui import QPainterPath
cx, cy = center.x(), center.y()
w, h = 22.0, 14.0
pen = QPen(QColor(C_ABYSS))
pen.setWidthF(1.5)
pen.setCosmetic(True)
grad = QLinearGradient(0, cy - h / 2, 0, cy + h / 2)
grad.setColorAt(0.0, QColor("#5BC0EB"))
grad.setColorAt(1.0, QColor("#1B7FB5"))
tp = QPainterPath()
tp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 2, 2)
self._scene.addPath(tp, pen, QBrush(grad))
# Diaphragm interno
diaph_pen = QPen(QColor(C_ABYSS))
diaph_pen.setWidthF(0.6)
diaph_pen.setStyle(Qt.DashLine)
diaph_pen.setCosmetic(True)
self._scene.addLine(cx, cy - h / 2 + 2, cx, cy + h / 2 - 2, diaph_pen)
# Manhole circle (top)
self._scene.addEllipse(cx - 2, cy - h / 2 - 1, 4, 4, pen, QBrush(QColor("#F2F5F9")))
self._label_below(cx, cy, prefix, dy=h / 2 + 6)
def _draw_thruster_icon(self, center, prefix: str) -> None:
"""Hélice de proa/popa: círculo con aspas radiales."""
cx, cy = center.x(), center.y()
r = 9.0
pen = QPen(QColor(C_ABYSS))
pen.setWidthF(1.5)
pen.setCosmetic(True)
self._scene.addEllipse(
cx - r, cy - r, 2 * r, 2 * r, pen, QBrush(QColor("#3A6BA8"))
)
# 4 aspas radiales
from PySide6.QtGui import QPainterPath
aspas = QPainterPath()
for ang in (0, 90, 180, 270):
import math
a = math.radians(ang)
aspas.moveTo(cx, cy)
aspas.lineTo(cx + r * 0.75 * math.cos(a), cy + r * 0.75 * math.sin(a))
aspas_pen = QPen(QColor(C_FOAM))
aspas_pen.setWidthF(1.6)
aspas_pen.setCosmetic(True)
self._scene.addPath(aspas, aspas_pen, QBrush(Qt.NoBrush))
self._scene.addEllipse(cx - 2, cy - 2, 4, 4, pen, QBrush(QColor(C_FOAM)))
self._label_below(cx, cy, prefix, dy=r + 4)
def _draw_hvac_icon(self, center, prefix: str) -> None:
"""Unidad HVAC: rectángulo + aspas de ventilador en frente."""
from PySide6.QtGui import QPainterPath
cx, cy = center.x(), center.y()
w, h = 18.0, 14.0
pen = QPen(QColor(C_ABYSS))
pen.setWidthF(1.5)
pen.setCosmetic(True)
hp = QPainterPath()
hp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 2, 2)
self._scene.addPath(hp, pen, QBrush(QColor("#B8C2D1")))
# Ventilador (círculo con 3 aspas curvas)
r = 5
self._scene.addEllipse(
cx - r, cy - r, 2 * r, 2 * r, pen, QBrush(QColor("#5A6B7F"))
)
aspas_pen = QPen(QColor(C_FOAM))
aspas_pen.setWidthF(1.2)
aspas_pen.setCosmetic(True)
from PySide6.QtGui import QPainterPath
for ang in (0, 120, 240):
import math
a = math.radians(ang)
asp = QPainterPath()
asp.moveTo(cx, cy)
asp.quadTo(
cx + r * 0.7 * math.cos(a + 0.6),
cy + r * 0.7 * math.sin(a + 0.6),
cx + r * 0.85 * math.cos(a),
cy + r * 0.85 * math.sin(a),
)
self._scene.addPath(asp, aspas_pen, QBrush(Qt.NoBrush))
self._label_below(cx, cy, prefix, dy=h / 2 + 5)
def _draw_generic_icon(self, center, prefix: str, color: str) -> None:
"""Fallback genérico (rectángulo neutro) para sistemas no contemplados."""
from PySide6.QtGui import QPainterPath
cx, cy = center.x(), center.y()
w, h = 14.0, 14.0
pen = QPen(QColor(C_ABYSS))
pen.setWidthF(1.2)
pen.setCosmetic(True)
gp = QPainterPath()
gp.addRoundedRect(cx - w / 2, cy - h / 2, w, h, 3, 3)
self._scene.addPath(gp, pen, QBrush(QColor(color)))
self._label_below(cx, cy, prefix, dy=h / 2 + 4)
def _draw_axes(self, length_m: float, beam_m: float) -> None: def _draw_axes(self, length_m: float, beam_m: float) -> None:
# Ruler X # Ruler X
+231
View File
@@ -0,0 +1,231 @@
"""Welcome screen del Studio.
Pantalla inicial cuando no hay proyecto abierto. Muestra al usuario las dos
acciones principales (crear nuevo / abrir existente) como botones grandes,
más una lista de proyectos recientes y enlaces a docs.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from vmssailor.studio.theme import (
C_ABYSS,
C_CYAN,
C_CYAN_DEEP,
C_FOAM,
C_FOG,
C_HORIZON,
C_MIDNIGHT,
C_SAND,
C_STEEL,
display_font,
mono_font,
ui_font,
)
BRAND_ROOT = Path(__file__).resolve().parents[2] / "docs" / "brand"
class WelcomeScreen(QWidget):
"""Pantalla de bienvenida con CTAs grandes."""
newProjectRequested = Signal()
openProjectRequested = Signal()
openRecentRequested = Signal(str) # path
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("welcomeScreen")
self.setStyleSheet(
f"#welcomeScreen {{ background: {C_ABYSS}; }}"
)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
outer.addStretch(1)
center = QWidget()
center.setMaximumWidth(880)
cl = QVBoxLayout(center)
cl.setSpacing(0)
cl.setContentsMargins(48, 0, 48, 0)
# Hero with logo + title
hero = QHBoxLayout()
hero.setSpacing(24)
logo_path = BRAND_ROOT / "logo-mark.svg"
if logo_path.exists():
logo = QLabel()
pix = QIcon(str(logo_path)).pixmap(120, 120)
logo.setPixmap(pix)
hero.addWidget(logo)
title_box = QVBoxLayout()
title_box.setSpacing(4)
title = QLabel("VMS-Sailor Studio")
title.setFont(display_font(36, 700))
title.setStyleSheet(f"color: {C_FOAM};")
title_box.addWidget(title)
sub = QLabel("VESSEL · MANAGEMENT · SYSTEM")
sub.setFont(ui_font(11))
sub.setStyleSheet(f"color: {C_HORIZON}; letter-spacing: 4px;")
title_box.addWidget(sub)
body = QLabel(
"Herramienta de ingeniería para configurar el VMS-Sailor de cada buque. "
"Crea un nuevo proyecto desde el wizard o abre un .vmsproj existente."
)
body.setWordWrap(True)
body.setFont(ui_font(11))
body.setStyleSheet(f"color: {C_SAND}; margin-top: 12px;")
title_box.addWidget(body)
title_box.addStretch(1)
hero.addLayout(title_box, 1)
cl.addLayout(hero)
cl.addSpacing(32)
# Two big CTAs
ctas = QHBoxLayout()
ctas.setSpacing(16)
ctas.addWidget(
self._make_cta(
title="Nuevo desde wizard",
subtitle="Wizard de 8 pasos: tipo de buque, plantilla, dimensiones,\n"
"sistemas, equipos, topología, confirmación.",
signal=self.newProjectRequested,
primary=True,
shortcut="Ctrl+N",
),
1,
)
ctas.addWidget(
self._make_cta(
title="Abrir proyecto",
subtitle="Carga un archivo .vmsproj existente desde disco.\n"
"Edita equipos, mímicos, tags y alarmas.",
signal=self.openProjectRequested,
primary=False,
shortcut="Ctrl+O",
),
1,
)
cl.addLayout(ctas)
cl.addSpacing(28)
# Quick links / docs
links_label = QLabel("DOCUMENTACIÓN")
links_label.setFont(ui_font(9))
links_label.setStyleSheet(
f"color: {C_FOG}; letter-spacing: 2.5px; margin-bottom: 8px;"
)
cl.addWidget(links_label)
links_row = QHBoxLayout()
links_row.setSpacing(8)
for txt, hint in (
("Sprint 0 — Fundaciones", "docs/architecture.md"),
("Sistema de coordenadas", "docs/coords.md"),
("Design System", "docs/design_system.md"),
("Mockups visuales", "docs/mockups/index.html"),
):
chip = QLabel(f" {txt} ")
chip.setFont(mono_font(9))
chip.setStyleSheet(
f"color: {C_SAND}; background: {C_MIDNIGHT}; "
f"border: 1px solid {C_STEEL}; border-radius: 12px; padding: 4px 10px;"
)
chip.setToolTip(hint)
links_row.addWidget(chip)
links_row.addStretch(1)
cl.addLayout(links_row)
outer.addWidget(center, alignment=Qt.AlignmentFlag.AlignHCenter)
outer.addStretch(2)
footer = QLabel(
"Propiedad intelectual de Álvaro · El Studio no se distribuye al cliente"
)
footer.setFont(mono_font(9))
footer.setStyleSheet(f"color: {C_FOG}; padding: 16px;")
footer.setAlignment(Qt.AlignmentFlag.AlignCenter)
outer.addWidget(footer)
def _make_cta(
self,
title: str,
subtitle: str,
signal,
primary: bool,
shortcut: str = "",
) -> QFrame:
frame = QFrame()
frame.setCursor(Qt.CursorShape.PointingHandCursor)
bg_hover = C_CYAN_DEEP if primary else C_STEEL
border = C_CYAN if primary else C_IRON_FALLBACK
bg = C_MIDNIGHT if not primary else "rgba(0,217,255,0.08)"
frame.setStyleSheet(
f"""
QFrame {{
background: {bg};
border: 1px solid {border};
border-radius: 14px;
min-height: 180px;
}}
QFrame:hover {{
background: {bg_hover};
border-color: {C_CYAN};
}}
"""
)
lay = QVBoxLayout(frame)
lay.setContentsMargins(24, 22, 24, 22)
lay.setSpacing(8)
t = QLabel(title)
t.setFont(display_font(22, 700))
t.setStyleSheet(f"color: {C_FOAM};")
lay.addWidget(t)
s = QLabel(subtitle)
s.setFont(ui_font(10))
s.setStyleSheet(f"color: {C_FOG};")
s.setWordWrap(True)
lay.addWidget(s)
lay.addStretch(1)
kbd_row = QHBoxLayout()
kbd_row.addStretch(1)
if shortcut:
kbd = QLabel(shortcut)
kbd.setFont(mono_font(9))
kbd.setStyleSheet(
f"color: {C_FOG}; background: {C_ABYSS}; "
f"border: 1px solid {C_STEEL}; border-radius: 4px; padding: 2px 8px;"
)
kbd_row.addWidget(kbd)
arrow = QLabel("")
arrow.setFont(display_font(22, 700))
arrow.setStyleSheet(f"color: {C_CYAN}; margin-left: 8px;")
kbd_row.addWidget(arrow)
lay.addLayout(kbd_row)
frame.mousePressEvent = lambda _ev: signal.emit() # type: ignore[method-assign]
return frame
# Fallback color para borde no-primary (no exportado en theme aún si quieres)
C_IRON_FALLBACK = "#2C3E5C"