diff --git a/.gitignore b/.gitignore index f0a89fb..9128a41 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/hyperwall/app.py b/hyperwall/app.py index 5c67b8a..e5c38ea 100644 --- a/hyperwall/app.py +++ b/hyperwall/app.py @@ -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 @@ -200,7 +201,7 @@ def main() -> None: pass app = QApplication(sys.argv) - app.setStyle("Fusion") + theme.apply(app) # 5. NVIDIA profile ensure_nvidia_profile() diff --git a/hyperwall/cell.py b/hyperwall/cell.py index c685877..f37bb65 100644 --- a/hyperwall/cell.py +++ b/hyperwall/cell.py @@ -68,6 +68,7 @@ apply_env_overrides, ) from .reliability import escalation_plan, is_stalled, should_park +from . import theme logger = logging.getLogger("HyperWall") @@ -77,27 +78,64 @@ def _s(px: int) -> int: return max(1, int(px * UI_SCALE)) +# Glassy translucent control bar, on-brand accent, rounded top. Buttons are +# borderless and quiet at rest, lifting to the accent on hover/active so the +# bar reads as one slab instead of a row of grey chips. CTRL_STYLE = f""" QFrame#controls {{ - background: rgba(22, 22, 22, 210); - border-top: 1px solid rgba(255, 255, 255, 22); - border-radius: 4px 4px 0 0; + background: {theme.rgba(theme.SURFACE_0, 0.86)}; + border-top: 1px solid {theme.rgba(theme.ACCENT_BRIGHT, 0.22)}; + border-radius: {_s(8)}px {_s(8)}px 0 0; }} - QLabel {{ color: #ccc; font-family: 'Segoe UI', system-ui, sans-serif; font-size: {_s(9)}px; background: transparent; }} + QLabel {{ color: {theme.TEXT_DIM}; font-family: {theme.FONT}; font-size: {_s(9)}px; background: transparent; }} QPushButton {{ - background: rgba(80, 80, 80, 180); border: 1px solid rgba(255,255,255,20); - border-radius: 2px; color: #eee; font-size: {_s(11)}px; padding: 1px; - min-width: {_s(22)}px; min-height: {_s(22)}px; max-width: {_s(22)}px; max-height: {_s(22)}px; + background: {theme.rgba('#ffffff', 0.06)}; border: none; + border-radius: {_s(5)}px; color: {theme.TEXT}; font-size: {_s(11)}px; padding: 1px; + min-width: {_s(24)}px; min-height: {_s(24)}px; max-width: {_s(24)}px; max-height: {_s(24)}px; }} - QPushButton:hover {{ background: #2563a8; border-color: #3b8edb; color: white; }} - QPushButton:checked {{ background: #1e4f78; border-color: #3b8edb; color: white; }} - QSlider::groove:horizontal {{ background: rgba(100,100,100,180); height: {_s(3)}px; border-radius: 1px; }} - QSlider::sub-page:horizontal {{ background: rgba(59,142,219,200); border-radius: 1px; }} + QPushButton:hover {{ background: {theme.ACCENT}; color: white; }} + QPushButton:pressed {{ background: {theme.ACCENT_DEEP}; }} + QPushButton:checked {{ background: {theme.ACCENT_DIM}; color: white; }} + /* Favorite / trash: active state is the colored glyph, not an accent block. */ + QPushButton#favBtn:checked, QPushButton#tagBtn:checked {{ background: {theme.rgba('#ffffff', 0.06)}; color: {theme.TEXT}; }} + QSlider::groove:horizontal {{ background: {theme.rgba('#ffffff', 0.16)}; height: {_s(3)}px; border-radius: {_s(2)}px; }} + QSlider::sub-page:horizontal {{ background: {theme.ACCENT}; border-radius: {_s(2)}px; }} QSlider::handle:horizontal {{ - background: rgba(220,220,220,220); width: {_s(8)}px; margin: -2px 0; border-radius: {_s(4)}px; + background: #ffffff; width: {_s(9)}px; height: {_s(9)}px; margin: {_s(-3)}px 0; border-radius: {_s(5)}px; }} + QSlider::handle:horizontal:hover {{ background: {theme.ACCENT_BRIGHT}; }} """ +# Title card = translucent pill with a left accent spine. Loading card reuses +# the pill but in accent, tracked with a gentle opacity pulse (see cell init). +_TITLE_STYLE = ( + f"color: {theme.TEXT}; background: {theme.rgba(theme.SURFACE_0, 0.82)};" + f" border-left: {_s(3)}px solid {theme.ACCENT};" + f" font-family: {theme.FONT}; font-size: {_s(13)}px; font-weight: 700;" + f" padding: {_s(5)}px {_s(14)}px; border-radius: {_s(4)}px;" +) +_LOADING_STYLE = ( + f"color: {theme.ACCENT_BRIGHT}; background: {theme.rgba(theme.SURFACE_0, 0.82)};" + f" font-family: {theme.FONT}; font-size: {_s(12)}px; font-weight: 800;" + f" letter-spacing: {_s(3)}px; padding: {_s(5)}px {_s(16)}px; border-radius: {_s(4)}px;" +) + +# Icon glyphs. A trailing VS15 (U+FE0E) forces the monochrome text glyph, which +# the control-bar QSS `color` then tints to match the bar; VS16 (U+FE0F) restores +# the native color glyph. The bar reads monochrome — only an *active* favorite +# (gold star) or trash flag (red bin) lights up, so those states pop at a glance. +_MONO = "︎" # VS15 - force monochrome text glyph +_COLOR = "️" # VS16 - force color emoji glyph +_G_PREV = "⏮" + _MONO +_G_PAUSE = "⏸" + _MONO # shown while playing (click → pause) +_G_PLAY = "▶" + _MONO # shown while paused (click → play) +_G_NEXT = "⏭" + _MONO +_G_LOOP = "🔁" + _MONO +_G_MUTE = "🔇" + _MONO +_G_UNMUTE = "🔊" + _MONO +_G_TRASH = "🗑" # presentation selector added by _refresh_tag_glyph() +_G_FAV = "⭐" # presentation selector added by _refresh_fav_glyph() + class ClickSlider(QSlider): """Slider that jumps to click position.""" @@ -144,6 +182,7 @@ def __init__(self, controller: Any): self._emby_session_id: str | None = None self._emby_item_id: str | None = None self._switching = False # set in play(), consumed in _handle_eof + self._audio_started = False # True once an audio track has been selected # Reliability / self-healing (Epic 2) self._last_progress_ts = 0.0 # monotonic ts of last time-pos advance @@ -195,12 +234,7 @@ def __init__(self, controller: Any): self._title_overlay = QLabel("", self) self._title_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) self._title_overlay.setWordWrap(False) - self._title_overlay.setStyleSheet( - f"color: white; background: rgba(0,0,0,180);" - f" font-family: 'Segoe UI', system-ui, sans-serif;" - f" font-size: {_s(13)}px; font-weight: 600;" - f" padding: {_s(5)}px {_s(14)}px; border-radius: 4px;" - ) + self._title_overlay.setStyleSheet(_TITLE_STYLE) self._title_overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self._title_overlay.hide() @@ -214,6 +248,17 @@ def __init__(self, controller: Any): self._overlay_show_timer.setSingleShot(True) self._overlay_show_timer.timeout.connect(self._fade_overlay_out) + # Looping opacity pulse for the "LOADING" card. Runs only between the + # cell becoming visible and its first frame — stopped by + # _show_title_overlay — so it never animates during steady playback. + self._loading_pulse = QPropertyAnimation(self._overlay_effect, b"opacity", self) + self._loading_pulse.setDuration(1100) + self._loading_pulse.setStartValue(1.0) + self._loading_pulse.setKeyValueAt(0.5, 0.4) + self._loading_pulse.setEndValue(1.0) + self._loading_pulse.setLoopCount(-1) + self._loading_pulse.setEasingCurve(QEasingCurve.Type.InOutSine) + self.setMouseTracking(True) self._sig_eof.connect(self._handle_eof, Qt.ConnectionType.QueuedConnection) @@ -267,6 +312,15 @@ def _ensure_mpv(self) -> None: m["mute"] = self.muted except Exception as e: logger.debug("mpv: failed to set initial mute: %s", e) + # Don't decode an audio track for a muted cell (the wall's default): + # aid=no skips audio demux+decode for that cell, which adds up across + # the grid. The track is (re)selected lazily on first unmute via + # _enable_audio_track(), so no reload is needed to hear a cell. + self._audio_started = not self.muted + try: + m["aid"] = "auto" if self._audio_started else "no" + except Exception as e: + logger.debug("mpv: failed to set initial aid: %s", e) if self.looping: try: m["loop-file"] = "inf" @@ -387,7 +441,7 @@ def showEvent(self, event: Any) -> None: self.video_frame.winId() # force native window creation if not self._played_anything and self.current_item is None: # Visual feedback while staggered startup loads content. - self._show_title_overlay("Loading…") + self._show_loading() def resizeEvent(self, event: Any) -> None: super().resizeEvent(event) @@ -449,6 +503,8 @@ def play(self, item: dict[str, Any], url: str) -> None: self.btn_fav.setChecked( item.get("UserData", {}).get("IsFavorite", False) ) + self._refresh_tag_glyph() + self._refresh_fav_glyph() # Determine if we need to recreate mpv need_create = self._mpv is None or self._force_transcode @@ -484,7 +540,7 @@ def play(self, item: dict[str, Any], url: str) -> None: self._mpv["mute"] = self.muted self._mpv.command("loadfile", url) self._paused = False - self.btn_play.setText("⏸") + self.btn_play.setText(_G_PAUSE) except Exception as e: self._switching = False logger.error("mpv loadfile failed: %s", e) @@ -535,14 +591,18 @@ def _btn(text: str, checkable: bool = False) -> QPushButton: b.setCheckable(checkable) return b - self.btn_prev = _btn("⏮") - self.btn_play = _btn("⏸") - self.btn_next = _btn("⏭") - self.btn_loop = _btn("🔁", checkable=True) - self.btn_tag = _btn("🗑", checkable=True) - self.btn_fav = _btn("⭐", checkable=True) - self.btn_mute = _btn("🔇", checkable=True) + self.btn_prev = _btn(_G_PREV) + self.btn_play = _btn(_G_PAUSE) + self.btn_next = _btn(_G_NEXT) + self.btn_loop = _btn(_G_LOOP, checkable=True) + self.btn_tag = _btn(_G_TRASH + _MONO, checkable=True) + self.btn_fav = _btn(_G_FAV + _MONO, checkable=True) + self.btn_mute = _btn(_G_MUTE, checkable=True) self.btn_mute.setChecked(True) + # Named so their active (checked) state keeps a neutral bar-tone fill — + # the colored glyph, not an accent block, signals fav/flag (see CTRL_STYLE). + self.btn_fav.setObjectName("favBtn") + self.btn_tag.setObjectName("tagBtn") self.vol_slider = QSlider(Qt.Orientation.Horizontal) self.vol_slider.setRange(0, 100) @@ -632,6 +692,8 @@ def _reposition_controls(self) -> None: def _show_title_overlay(self, title: str) -> None: self._overlay_show_timer.stop() self._overlay_anim.stop() + self._loading_pulse.stop() + self._title_overlay.setStyleSheet(_TITLE_STYLE) self._title_overlay.setText(title) self._overlay_effect.setOpacity(1.0) self._title_overlay.adjustSize() @@ -640,6 +702,25 @@ def _show_title_overlay(self, title: str) -> None: self._title_overlay.raise_() self._overlay_show_timer.start(OVERLAY_SHOW_MS) + def _show_loading(self) -> None: + """Show a pulsing 'LOADING' card until the first frame arrives. + + Unlike the title card this does not auto-fade — it stays (pulsing) + until play() swaps in the real title, so a slow-loading cell still + reads as busy rather than blank. + """ + self._overlay_show_timer.stop() + self._overlay_anim.stop() + self._title_overlay.setStyleSheet(_LOADING_STYLE) + self._title_overlay.setText("LOADING") + self._title_overlay.adjustSize() + self._reposition_overlay() + self._title_overlay.show() + self._title_overlay.raise_() + self._overlay_effect.setOpacity(1.0) + self._loading_pulse.stop() + self._loading_pulse.start() + def _reposition_overlay(self) -> None: vw = self.video_frame ovl = self._title_overlay @@ -693,7 +774,7 @@ def _seek_release(self) -> None: self._mpv.seek(target, "absolute") self._mpv["pause"] = False self._paused = False - self.btn_play.setText("⏸") + self.btn_play.setText(_G_PAUSE) except Exception as e: logger.warning("seek failed: %s", e) self._dragging = False @@ -705,7 +786,7 @@ def _toggle_play(self) -> None: new_pause = not bool(self._mpv["pause"]) self._mpv["pause"] = new_pause self._paused = new_pause - self.btn_play.setText("▶" if new_pause else "⏸") + self.btn_play.setText(_G_PLAY if new_pause else _G_PAUSE) except Exception as e: logger.debug("toggle_play failed: %s", e) @@ -717,15 +798,32 @@ def _toggle_loop(self) -> None: except Exception as e: logger.debug("toggle_loop failed: %s", e) + def _enable_audio_track(self) -> None: + """Lazily (re)select an audio track the first time a cell is unmuted. + + Muted cells load with aid=no (see _ensure_mpv) so the grid doesn't + decode audio it never plays; this restores the track on demand without + a full reload. No-op once a track is already selected. + """ + if self._audio_started or self._mpv is None: + return + try: + self._mpv["aid"] = "auto" + self._audio_started = True + except Exception as e: + logger.debug("enable audio track failed: %s", e) + def _toggle_mute(self) -> None: muted = self.btn_mute.isChecked() self.muted = muted + if not muted: + self._enable_audio_track() if self._mpv is not None: try: self._mpv["mute"] = muted except Exception as e: logger.debug("toggle_mute failed: %s", e) - self.btn_mute.setText("🔇" if muted else "🔊") + self.btn_mute.setText(_G_MUTE if muted else _G_UNMUTE) if not muted and self.vol_slider.value() == 0: self.vol_slider.setValue(70) @@ -737,13 +835,14 @@ def _vol_changed(self, val: int) -> None: logger.debug("vol_changed failed: %s", e) if val > 0 and self.muted: self.muted = False + self._enable_audio_track() if self._mpv is not None: try: self._mpv["mute"] = False except Exception as e: logger.debug("vol_changed mute-clear failed: %s", e) self.btn_mute.setChecked(False) - self.btn_mute.setText("🔊") + self.btn_mute.setText(_G_UNMUTE) elif val == 0 and not self.muted: self.muted = True if self._mpv is not None: @@ -752,7 +851,7 @@ def _vol_changed(self, val: int) -> None: except Exception as e: logger.debug("vol_changed mute-set failed: %s", e) self.btn_mute.setChecked(True) - self.btn_mute.setText("🔇") + self.btn_mute.setText(_G_MUTE) def _toggle_tag(self) -> None: if not self.current_item: @@ -769,15 +868,29 @@ def _toggle_tag(self) -> None: tags.append("ToDelete") self.current_item["Tags"] = tags self.btn_tag.setChecked("ToDelete" in tags) + self._refresh_tag_glyph() self.controller.update_tags(self.current_item) def _toggle_fav(self) -> None: if not self.current_item: return new = self.btn_fav.isChecked() + self._refresh_fav_glyph() self.current_item.setdefault("UserData", {})["IsFavorite"] = new self.controller.update_favorite(self.current_item["Id"], new) + def _refresh_fav_glyph(self) -> None: + """Gold star when favorited, monochrome otherwise.""" + self.btn_fav.setText( + _G_FAV + (_COLOR if self.btn_fav.isChecked() else _MONO) + ) + + def _refresh_tag_glyph(self) -> None: + """Red bin when flagged for deletion, monochrome otherwise.""" + self.btn_tag.setText( + _G_TRASH + (_COLOR if self.btn_tag.isChecked() else _MONO) + ) + # ── EOF / error handling ────────────────────────────────────────────── def _handle_eof(self, gen: int, reason: str) -> None: diff --git a/hyperwall/constants.py b/hyperwall/constants.py index 9aa1426..b03c911 100644 --- a/hyperwall/constants.py +++ b/hyperwall/constants.py @@ -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, diff --git a/hyperwall/theme.py b/hyperwall/theme.py new file mode 100644 index 0000000..84e8dc9 --- /dev/null +++ b/hyperwall/theme.py @@ -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) diff --git a/hyperwall/wizard.py b/hyperwall/wizard.py index daad382..622e1a7 100644 --- a/hyperwall/wizard.py +++ b/hyperwall/wizard.py @@ -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, @@ -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): @@ -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") @@ -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) @@ -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 { diff --git a/tests/run_all.py b/tests/run_all.py index 5a0869f..0512e57 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -26,6 +26,7 @@ "test_playlist", "test_scenes", "test_backends", + "test_theme", ] diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..b44d9d0 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,63 @@ +""" +Unit tests for hyperwall.theme — pure palette + QSS string helpers. + +No PyQt import needed (theme keeps Qt out of module load). Run: + python tests/test_theme.py +""" + +from __future__ import annotations + +import os +import re +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +from hyperwall import theme # noqa: E402 + + +def test_rgba_basic(): + assert theme.rgba("#3b8edb", 0.5) == "rgba(59, 142, 219, 0.500)" + + +def test_rgba_handles_no_hash(): + assert theme.rgba("000000", 1.0) == "rgba(0, 0, 0, 1.000)" + + +def test_palette_constants_are_hex(): + for name in ("ACCENT", "SURFACE_0", "TEXT", "BORDER", "DANGER"): + val = getattr(theme, name) + assert re.fullmatch(r"#[0-9a-fA-F]{6}", val), f"{name}={val!r} not #rrggbb" + + +def test_accent_is_brand_blue(): + # The web remote hard-codes #3b8edb; keep the desktop accent in lockstep. + assert theme.ACCENT.lower() == "#3b8edb" + + +def test_dialog_qss_is_populated_and_themed(): + qss = theme.dialog_qss() + assert "QDialog" in qss and "QPushButton" in qss + # references the palette, not stray literals + assert theme.ACCENT in qss + assert theme.SURFACE_0 in qss + + +def run_all() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + passed = failed = 0 + for t in tests: + try: + t() + passed += 1 + print(f" PASS {t.__name__}") + except Exception as e: # noqa: BLE001 + failed += 1 + print(f" FAIL {t.__name__}: {e}") + print(f"\n{passed} passed, {failed} failed out of {len(tests)} tests.") + return failed + + +if __name__ == "__main__": + sys.exit(run_all())