Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ hyperwall_v8.exe
.hyperwall_v8_nvprofile.sentinel

# ── Downloaded tools / DLLs ────────────────────────────────────────────────
tools/nvidiaProfileInspector.exe
tools/
mpv-2.dll
libmpv-2.dll
7zr.exe
Expand Down
3 changes: 2 additions & 1 deletion hyperwall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .emby import EmbyClient, CleanupWorker
from .backends import resolve_backend
from .nvidia import ensure_nvidia_profile, maybe_relaunch_in_isolation
from . import theme
from .wizard import SetupWizard
from .wall import WallController, MouseIdleHider

Expand Down Expand Up @@ -200,7 +201,7 @@ def main() -> None:
pass

app = QApplication(sys.argv)
app.setStyle("Fusion")
theme.apply(app)

# 5. NVIDIA profile
ensure_nvidia_profile()
Expand Down
177 changes: 145 additions & 32 deletions hyperwall/cell.py

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion hyperwall/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@ def _int_env(name: str, default: int, lo: int, hi: int) -> int:
MPV_OPTS: dict[str, object] = dict(
vo="gpu-next",
gpu_api="d3d11",
hwdec="nvdec-copy",
# d3d11va decodes straight into D3D11 textures that the gpu-next/d3d11
# renderer consumes directly — no decode-surface copy back to system RAM
# like nvdec-copy, and no CUDA↔D3D11 interop cost like non-copy nvdec.
# Benchmarked on skyhawk (RTX 5070 Ti, 8-cell wall): ~30% lower VRAM
# (~3.2 GB → ~2.2 GB) at equal CPU/power and zero frame drops. If a
# driver/GPU regresses, override with HYPERWALL_HWDEC=nvdec-copy.
hwdec="d3d11va",
profile="fast",
video_sync="audio",
video_sync_max_video_change=5,
Expand Down
126 changes: 126 additions & 0 deletions hyperwall/theme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""
Hyperwall — one source of truth for the look.

Pure data + string helpers (no PyQt import at module load) so the palette can
be imported anywhere cheaply, including headless tests. `apply(app)` opts into
a matching Fusion QPalette for native widgets (dialogs, message boxes).

Brand accent stays the Hyperwall blue (#3b8edb) used by the web remote, so all
surfaces read as one product. Video cells themselves stay pure black — this
theme styles the *chrome* (wizard, control bars, overlays), never the frame.
"""

from __future__ import annotations

# ── Palette ───────────────────────────────────────────────────────────────────
# Cool, near-black surfaces layered light→dark by elevation.
SURFACE_0 = "#0e1116" # dialog / window base
SURFACE_1 = "#151a21" # group / panel
SURFACE_2 = "#1c222c" # inputs, list rows
SURFACE_3 = "#252d39" # hover / elevated
VIDEO_BG = "#000000" # behind video frames — always pure black

BORDER = "#2a323d"
BORDER_STRONG = "#3a4553"

TEXT = "#e8ebf0" # primary
TEXT_DIM = "#9aa4b2" # secondary / labels
TEXT_MUTED = "#5c6675" # tertiary / hints

# On-brand accent ramp.
ACCENT = "#3b8edb"
ACCENT_BRIGHT = "#5aa7f0"
ACCENT_DIM = "#1e4f78"
ACCENT_DEEP = "#163a5c"

DANGER = "#d43535"
DANGER_DIM = "#8b1a1a"

FONT = "'Segoe UI', system-ui, sans-serif"

RADIUS = 6
RADIUS_SM = 4


def rgba(hex_color: str, alpha: float) -> str:
"""`rgba(r, g, b, a)` from a #rrggbb string and 0.0–1.0 alpha."""
h = hex_color.lstrip("#")
r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4))
return f"rgba({r}, {g}, {b}, {alpha:.3f})"


def dialog_qss() -> str:
"""Cohesive stylesheet for dialogs/wizard-style chrome (QDialog subtree)."""
return f"""
QDialog, QWidget#hwRoot {{
background: {SURFACE_0}; color: {TEXT}; font-family: {FONT};
}}
QLabel {{ color: {TEXT_DIM}; font-size: 11px; background: transparent; }}
QGroupBox {{
border: 1px solid {BORDER}; border-radius: {RADIUS}px; margin-top: 10px;
padding-top: 6px; font-weight: 700; font-size: 11px;
color: {ACCENT}; background: {SURFACE_1};
}}
QGroupBox::title {{
subcontrol-origin: margin; left: 10px; padding: 0 5px;
letter-spacing: 1px;
}}
QListWidget {{
background: {SURFACE_2}; border: 1px solid {BORDER};
border-radius: {RADIUS_SM}px; color: {TEXT}; outline: none; padding: 3px;
}}
QListWidget::item {{ padding: 6px 8px; border-radius: {RADIUS_SM}px; }}
QListWidget::item:hover {{ background: {SURFACE_3}; }}
QListWidget::item:selected {{
background: {ACCENT_DIM}; color: white;
}}
QSpinBox {{
background: {SURFACE_2}; color: {TEXT};
border: 1px solid {BORDER}; border-radius: {RADIUS_SM}px;
padding: 5px 6px; min-width: 54px;
}}
QSpinBox:focus {{ border-color: {ACCENT}; }}
QPushButton {{
background: {ACCENT_DIM}; color: white; border: none;
padding: 10px 22px; font-weight: 700; font-size: 13px;
border-radius: {RADIUS}px;
}}
QPushButton:hover {{ background: {ACCENT}; }}
QPushButton:pressed {{ background: {ACCENT_DEEP}; }}
QToolTip {{
background: {SURFACE_3}; color: {TEXT};
border: 1px solid {BORDER_STRONG}; padding: 4px 6px;
}}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }}
QScrollBar::handle:vertical {{
background: {BORDER_STRONG}; border-radius: 5px; min-height: 24px;
}}
QScrollBar::handle:vertical:hover {{ background: {TEXT_MUTED}; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
"""


def apply(app) -> None:
"""Apply Fusion + a matching dark QPalette so native chrome (QMessageBox,
combo popups, focus rings) blends with the custom stylesheets."""
from PyQt6.QtGui import QColor, QPalette

app.setStyle("Fusion")

def c(hexstr: str) -> "QColor":
return QColor(hexstr)

pal = QPalette()
pal.setColor(QPalette.ColorRole.Window, c(SURFACE_0))
pal.setColor(QPalette.ColorRole.WindowText, c(TEXT))
pal.setColor(QPalette.ColorRole.Base, c(SURFACE_2))
pal.setColor(QPalette.ColorRole.AlternateBase, c(SURFACE_1))
pal.setColor(QPalette.ColorRole.Text, c(TEXT))
pal.setColor(QPalette.ColorRole.Button, c(SURFACE_1))
pal.setColor(QPalette.ColorRole.ButtonText, c(TEXT))
pal.setColor(QPalette.ColorRole.Highlight, c(ACCENT))
pal.setColor(QPalette.ColorRole.HighlightedText, c("#ffffff"))
pal.setColor(QPalette.ColorRole.ToolTipBase, c(SURFACE_3))
pal.setColor(QPalette.ColorRole.ToolTipText, c(TEXT))
pal.setColor(QPalette.ColorRole.PlaceholderText, c(TEXT_MUTED))
app.setPalette(pal)
113 changes: 86 additions & 27 deletions hyperwall/wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QPainter
from PyQt6.QtWidgets import (
QDialog,
QGroupBox,
Expand All @@ -19,9 +20,51 @@
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)

from . import VERSION_SHORT
from . import theme
from .constants import UI_SCALE


def _s(px: int) -> int:
return max(1, int(px * UI_SCALE))


class _GridPreview(QWidget):
"""A tiny live diagram of the per-display grid (rows × cols of cells)."""

def __init__(self, rows: int = 2, cols: int = 2):
super().__init__()
self._rows = rows
self._cols = cols
self.setFixedSize(_s(148), _s(92))

def set_grid(self, rows: int, cols: int) -> None:
self._rows, self._cols = max(1, rows), max(1, cols)
self.update()

def paintEvent(self, _event: Any) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
p.setPen(Qt.PenStyle.NoPen)
# bezel
p.setBrush(QColor(theme.SURFACE_0))
p.drawRoundedRect(self.rect(), _s(6), _s(6))
gap = _s(3)
pad = _s(6)
w = self.width() - 2 * pad
h = self.height() - 2 * pad
cw = (w - gap * (self._cols - 1)) / self._cols
ch = (h - gap * (self._rows - 1)) / self._rows
p.setBrush(QColor(theme.ACCENT))
for r in range(self._rows):
for c in range(self._cols):
x = pad + c * (cw + gap)
y = pad + r * (ch + gap)
p.drawRoundedRect(int(x), int(y), int(cw), int(ch), _s(2), _s(2))
p.end()


class SetupWizard(QDialog):
Expand All @@ -38,40 +81,34 @@ def __init__(
):
super().__init__()
self.setWindowTitle(f"HyperWall {VERSION_SHORT}")
self.resize(720, 540)
self.setStyleSheet("""
QDialog { background: #0e0e0e; color: #eee; font-family: 'Segoe UI'; }
QGroupBox {
border: 1px solid #2a2a2a; border-radius: 4px; margin-top: 8px;
font-weight: bold; font-size: 11px; color: #3b8edb; background: #141414;
}
QGroupBox::title { subcontrol-origin: margin; left: 8px; padding: 0 4px; }
QListWidget { background: #181818; border: 1px solid #2a2a2a; color: #ccc; outline: none; }
QListWidget::item:selected { background: #1e4f78; color: white; }
QSpinBox { background: #181818; color: white; border: 1px solid #333; padding: 4px; min-width: 50px; }
QPushButton {
background: #1e4f78; color: white; border: none; padding: 10px 24px;
font-weight: bold; border-radius: 4px; font-size: 13px;
min-width: 0; min-height: 0; max-width: 9999px; max-height: 9999px;
}
QPushButton:hover { background: #3b8edb; }
QLabel { color: #888; font-size: 11px; }
""")
self.resize(_s(760), _s(560))
self.setStyleSheet(theme.dialog_qss())

self._screen_map: dict[str, Any] = {}

layout = QVBoxLayout(self)
layout.setContentsMargins(24, 20, 24, 20)
layout.setSpacing(14)
layout.setContentsMargins(_s(26), _s(22), _s(26), _s(22))
layout.setSpacing(_s(14))

title = QLabel(f"HYPERWALL {VERSION_SHORT}")
# ── Header ──
header = QVBoxLayout()
header.setSpacing(0)
title = QLabel("HYPERWALL")
title.setStyleSheet(
"font-size: 24px; font-weight: 900; color: white; letter-spacing: 3px;"
f"font-size: {_s(26)}px; font-weight: 900; color: {theme.TEXT};"
f" letter-spacing: {_s(4)}px; background: transparent;"
)
layout.addWidget(title)
subtitle = QLabel(f"video wall · v{VERSION_SHORT}")
subtitle.setStyleSheet(
f"font-size: {_s(11)}px; color: {theme.ACCENT}; letter-spacing: {_s(2)}px;"
f" background: transparent;"
)
header.addWidget(title)
header.addWidget(subtitle)
layout.addLayout(header)

panels = QHBoxLayout()
panels.setSpacing(14)
panels.setSpacing(_s(14))

# ── Displays ──
grp_disp = QGroupBox("DISPLAYS")
Expand Down Expand Up @@ -112,9 +149,10 @@ def __init__(

layout.addLayout(panels)

# ── Grid ──
# ── Grid + live preview ──
grp_grid = QGroupBox("LAYOUT")
lg = QHBoxLayout(grp_grid)
lg.setSpacing(_s(12))
self.rows = QSpinBox()
self.rows.setRange(1, 6)
self.rows.setValue(last_rows)
Expand All @@ -123,15 +161,36 @@ def __init__(
self.cols.setValue(last_cols)
lg.addWidget(QLabel("ROWS"))
lg.addWidget(self.rows)
lg.addSpacing(20)
lg.addSpacing(_s(12))
lg.addWidget(QLabel("COLS"))
lg.addWidget(self.cols)
lg.addSpacing(_s(16))

self.preview = _GridPreview(last_rows, last_cols)
lg.addWidget(self.preview)
self.lbl_cells = QLabel()
self.lbl_cells.setStyleSheet(
f"color: {theme.TEXT_DIM}; font-size: {_s(11)}px; background: transparent;"
)
lg.addWidget(self.lbl_cells)
lg.addStretch()

btn = QPushButton("▶ INITIALIZE SYSTEM")
btn.clicked.connect(self.accept)
btn.setDefault(True) # Enter starts the wall
btn.setAutoDefault(True)
lg.addWidget(btn)
layout.addWidget(grp_grid)

self.rows.valueChanged.connect(self._sync_preview)
self.cols.valueChanged.connect(self._sync_preview)
self._sync_preview()

def _sync_preview(self) -> None:
r, c = self.rows.value(), self.cols.value()
self.preview.set_grid(r, c)
self.lbl_cells.setText(f"{r * c} cells / display")

def get_settings(self) -> dict[str, Any]:
"""Return the selected configuration."""
return {
Expand Down
1 change: 1 addition & 0 deletions tests/run_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"test_playlist",
"test_scenes",
"test_backends",
"test_theme",
]


Expand Down
Loading
Loading