Files
duck-preview/app/overlay/overlay_widget.py

128 lines
4.2 KiB
Python

"""Overlay Widget — transparent layer rendered above the video preview."""
from __future__ import annotations
from PySide6.QtCore import QRect, Qt, Slot
from PySide6.QtGui import QColor, QFont, QPainter, QPen
from PySide6.QtWidgets import QWidget
from app.config import (
OVERLAY_BG_COLOR,
OVERLAY_FONT_SIZE,
OVERLAY_MARGIN,
OVERLAY_PADDING,
OVERLAY_TEXT_COLOR,
)
from app.telemetry.telemetry_collector import TelemetrySnapshot
class OverlayWidget(QWidget):
"""
Semi-transparent performance metrics overlay.
Sits on top of the video widget (same parent, raised).
Does NOT intercept mouse events — clicks pass through to the video widget.
Usage:
overlay = OverlayWidget(parent=main_window)
telemetry_collector.metrics_updated.connect(overlay.update_metrics)
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
# Child widget — NO window flags (FramelessWindowHint would detach it
# from the parent and create an invisible top-level window).
# WA_TranslucentBackground on a child only works when the parent is
# also translucent, so we paint the background ourselves in paintEvent.
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
self._snapshot: TelemetrySnapshot | None = None
self._visible_overlay: bool = True
# Font
self._font = QFont("Monospace")
self._font.setStyleHint(QFont.StyleHint.TypeWriter)
self._font.setPointSize(OVERLAY_FONT_SIZE)
self._font.setBold(False)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@Slot(object)
def update_metrics(self, snapshot: TelemetrySnapshot) -> None:
"""Receive a new telemetry snapshot and trigger a repaint."""
self._snapshot = snapshot
if self._visible_overlay:
self.update()
def set_overlay_visible(self, visible: bool) -> None:
self._visible_overlay = visible
self.update()
def toggle_overlay(self) -> None:
self.set_overlay_visible(not self._visible_overlay)
# ------------------------------------------------------------------
# Qt painting
# ------------------------------------------------------------------
def paintEvent(self, event) -> None: # noqa: N802
if not self._visible_overlay or self._snapshot is None:
return
lines = self._format_lines(self._snapshot)
if not lines:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setFont(self._font)
fm = painter.fontMetrics()
line_height = fm.height()
max_width = max(fm.horizontalAdvance(line) for line in lines)
box_w = max_width + OVERLAY_PADDING * 2
box_h = line_height * len(lines) + OVERLAY_PADDING * 2
x = OVERLAY_MARGIN
y = OVERLAY_MARGIN
# Background rectangle
bg = QColor(*OVERLAY_BG_COLOR)
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(QRect(x, y, box_w, box_h), 6, 6)
# Text
text_color = QColor(*OVERLAY_TEXT_COLOR)
painter.setPen(QPen(text_color))
text_x = x + OVERLAY_PADDING
text_y = y + OVERLAY_PADDING + fm.ascent()
for line in lines:
painter.drawText(text_x, text_y, line)
text_y += line_height
painter.end()
# ------------------------------------------------------------------
# Private
# ------------------------------------------------------------------
@staticmethod
def _format_lines(snap: TelemetrySnapshot) -> list[str]:
lines = [
f"FPS {snap.fps:>6.1f}",
f"Frame {snap.frame_time_ms:>6.1f} ms",
f"Drop {snap.dropped_frames:>6d}",
f"CPU {snap.cpu_percent:>5.1f} %",
]
if snap.memory_mb is not None:
lines.append(f"Mem {snap.memory_mb:>5.0f} MB")
return lines