From c8a61672729dd96b91fc732f467b0c0b17886010 Mon Sep 17 00:00:00 2001 From: tcconnally Date: Thu, 2 Jul 2026 22:41:56 -0500 Subject: [PATCH 1/2] feat(ui+perf): d3d11va decode, muted-cell audio skip, cohesive theme + slicker wizard/cells Performance (measured on skyhawk, RTX 5070 Ti, 8-cell wall): - Default hwdec nvdec-copy -> d3d11va. Decodes straight into the D3D11 textures gpu-next/d3d11 renders, avoiding nvdec-copy's surface copy-back and non-copy nvdec's CUDA<->D3D11 interop cost. ~30% less VRAM (~3.2GB -> ~2.2GB) at equal CPU/power, zero frame drops across two A/B passes. HYPERWALL_HWDEC override retained as an escape hatch. - Muted cells (the wall's default) load with aid=no, skipping audio demux/decode across the grid; the track is lazily (re)selected on first unmute via _enable_audio_track(), no reload needed. UI: - New hyperwall/theme.py: one source of truth for palette (on-brand #3b8edb accent), typography, radii + a dialog QSS and a Fusion dark QPalette. Pure strings, no Qt at import (headless-test friendly). app.py applies it. - Cell control bar: glassy translucent slab, borderless rounded quiet buttons that lift to the accent on hover/checked, slimmer accent seek. - Title card is a translucent pill with an accent spine; startup shows a pulsing LOADING card (animates only pre-first-frame, never during playback). - Setup wizard: themed, branded header, accent selection highlights, a live ROWS x COLS grid preview + "N cells / display" readout, Enter-to-start. Tests: add tests/test_theme.py (pure helpers); 85/85 suites green. Rebuilt and validated on skyhawk: hwdec=d3d11va live, isolation intact, cells play with no retry/escalation. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 +- hyperwall/app.py | 3 +- hyperwall/cell.py | 113 +++++++++++++++++++++++++++++------- hyperwall/constants.py | 8 ++- hyperwall/theme.py | 126 +++++++++++++++++++++++++++++++++++++++++ hyperwall/wizard.py | 113 +++++++++++++++++++++++++++--------- tests/run_all.py | 1 + tests/test_theme.py | 63 +++++++++++++++++++++ 8 files changed, 380 insertions(+), 49 deletions(-) create mode 100644 hyperwall/theme.py create mode 100644 tests/test_theme.py 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..f3a1066 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,46 @@ 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; }} + 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;" +) + class ClickSlider(QSlider): """Slider that jumps to click position.""" @@ -144,6 +164,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 +216,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 +230,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 +294,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 +423,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) @@ -632,6 +668,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 +678,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 @@ -717,9 +774,26 @@ 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 @@ -737,6 +811,7 @@ 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 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()) From ef360d6c628b67de1fb323fc0cc2a1120416e8e6 Mon Sep 17 00:00:00 2001 From: tcconnally Date: Thu, 2 Jul 2026 22:56:56 -0500 Subject: [PATCH 2/2] feat(ui): monochrome control-bar icons except active favorite/trash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Force text-presentation (VS15) on the control glyphs so they render monochrome and inherit the bar's tint, and switch only the favorite star and trash bin to their color glyph (VS16) when active — so a favorited or flagged cell pops at a glance while the bar stays quiet. Fav/trash also drop the accent :checked fill (objectName override) so the colored glyph is the sole active signal. Co-Authored-By: Claude Opus 4.8 --- hyperwall/cell.py | 64 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/hyperwall/cell.py b/hyperwall/cell.py index f3a1066..f37bb65 100644 --- a/hyperwall/cell.py +++ b/hyperwall/cell.py @@ -96,6 +96,8 @@ def _s(px: int) -> int: 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 {{ @@ -118,6 +120,22 @@ def _s(px: int) -> int: 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.""" @@ -485,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 @@ -520,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) @@ -571,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) @@ -750,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 @@ -762,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) @@ -799,7 +823,7 @@ def _toggle_mute(self) -> None: 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) @@ -818,7 +842,7 @@ def _vol_changed(self, val: int) -> None: 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: @@ -827,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: @@ -844,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: