"""Reusable Qt widgets""" from __future__ import annotations import math from typing import Any from .qt import require_qt QtCore, QtGui, QtWidgets = require_qt() # --------------------------------------------------------------------------- # TODO P3.1: Add _STATE_COLORS and _DEFAULT_COLOR module-level constants here # # THINKING: # Module-level avoids reconstructing the dict on every paintEvent repaint. # Single source of truth for colors used in two places: drawing calls (P3.2) # and the legend swatch (P3.3). _DEFAULT_COLOR retains the original orange # so any snapshot predating P1.2 renders with pre-existing orange rather than # silently going red. Cross-reference: used by P3.2 and P3.3. # # _STATE_COLORS: dict[str, str] = { # "ready": "#22c55e", # "aligning": "#f59e0b", # "fault": "#ef4444", # } # _DEFAULT_COLOR = "#f97316" # defensive fallback; unexpected alignment_state only # --------------------------------------------------------------------------- _STATE_COLORS: dict[str, str] = { "ready": "#22c55e", "aligning": "#f59e0b", "fault": "#ef4444", } _DEFAULT_COLOR = "#f97316" class MonitorPlot(QtWidgets.QWidget): """Operator monitor plot driven by a UI snapshot.""" def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.setMinimumSize(430, 390) self._snapshot: Any | None = None def set_snapshot(self, snapshot: Any | None) -> None: self._snapshot = snapshot self.update() def paintEvent(self, event: QtGui.QPaintEvent) -> None: del event painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) rect = self.rect().adjusted(0, 0, -1, -1) painter.fillRect(rect, QtGui.QColor("#ffffff")) legend_height = 32 plot_rect = rect.adjusted(30, 28, -30, -(legend_height + 16)) center = QtCore.QPointF(plot_rect.center()) radius = min(plot_rect.width(), plot_rect.height()) * 0.34 painter.setPen( QtGui.QPen(QtGui.QColor("#93c5fd"), 2, QtCore.Qt.PenStyle.DashLine) ) painter.drawEllipse(center, radius, radius) painter.setPen(QtGui.QPen(QtGui.QColor("#64748b"), 1)) for label, angle in [ ("CAM 1", -90), ("CAM 2", 0), ("CAM 3", 90), ("CAM 4", 180), ]: point = _polar(center, radius * 1.18, angle) painter.drawText( _camera_label_rect(point, angle), QtCore.Qt.AlignmentFlag.AlignCenter, label, ) if self._snapshot: reference_radius = max(float(self._snapshot.reference_radius_mm), 1.0) detected_radius = float(self._snapshot.detected_radius_mm) detected_center_mm = self._snapshot.detected_center_mm scale = radius / reference_radius detected_center = QtCore.QPointF( center.x() + float(detected_center_mm[0]) * scale, center.y() + float(detected_center_mm[1]) * scale, ) color = QtGui.QColor( _STATE_COLORS.get(self._snapshot.alignment_state, _DEFAULT_COLOR) ) painter.setPen(QtGui.QPen(color, 2)) painter.drawEllipse( detected_center, detected_radius * scale, detected_radius * scale ) painter.setPen(QtGui.QPen(QtGui.QColor("#2563eb"), 7)) painter.drawPoint(center) painter.setPen(QtGui.QPen(color, 7)) painter.drawLine(center, detected_center) painter.drawPoint(detected_center) else: painter.setPen(QtGui.QColor("#64748b")) painter.drawText( plot_rect, QtCore.Qt.AlignmentFlag.AlignCenter, "No wafer read" ) _draw_plot_legend( painter, rect, rect.bottom() - legend_height // 2, _STATE_COLORS.get( self._snapshot.alignment_state if self._snapshot else "", _DEFAULT_COLOR, ), ) painter.end() class OffsetCard(QtWidgets.QFrame): """Metric card with a separate unit label.""" def __init__( self, label: str, unit: str, value: str = "--", parent: QtWidgets.QWidget | None = None, ) -> None: super().__init__(parent) self.setObjectName("MetricCard") layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(16, 10, 16, 10) layout.setSpacing(4) label_widget = QtWidgets.QLabel(label) label_widget.setObjectName("Metric") row = QtWidgets.QHBoxLayout() row.setSpacing(8) self.value_widget = QtWidgets.QLabel(value) self.value_widget.setObjectName("Value") unit_widget = QtWidgets.QLabel(unit) unit_widget.setObjectName("Unit") row.addWidget(self.value_widget) row.addWidget(unit_widget) row.addStretch(1) layout.addWidget(label_widget) layout.addLayout(row) def set_value(self, value: str) -> None: self.value_widget.setText(value) class CameraStatusCard(QtWidgets.QFrame): """Camera health row with a colored status badge.""" def __init__(self, label: str, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("MetricCard") layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(12) self.name_label = QtWidgets.QLabel(label) self.name_label.setObjectName("Metric") self.badge = QtWidgets.QLabel("Wait") self.badge.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.badge.setMinimumWidth(66) layout.addWidget(self.name_label) layout.addStretch(1) layout.addWidget(self.badge) self.set_status("wait") def set_label(self, label: str) -> None: self.name_label.setText(label) def set_status(self, status: str, reading_text: str = "") -> None: normalized = status.lower() text = "OK" color = "#e8fff6" fg = "#006a55" if normalized in {"warn", "noisy"}: text = "Noisy" color = "#fff3dc" fg = "#835008" elif normalized == "fail": text = "Fail" color = "#ffe8e8" fg = "#a53535" elif normalized == "wait": text = "Wait" color = "#353632" fg = "#b8b8b2" self.badge.setText(reading_text or text) self.badge.setStyleSheet( f"background: {color}; color: {fg}; border-radius: 15px; " "padding: 6px 12px; font-weight: 750;" ) def _polar(center: QtCore.QPointF, radius: float, angle_deg: float) -> QtCore.QPointF: radians = math.radians(angle_deg) return QtCore.QPointF( center.x() + math.cos(radians) * radius, center.y() + math.sin(radians) * radius, ) def _draw_plot_legend( painter: QtGui.QPainter, rect: QtCore.QRect, legend_y: int, detected_color: str = _DEFAULT_COLOR, ) -> None: """Draw Reference / Detected legend centered with measured spacing.""" line_width = 36 text_gap = 8 entry_gap = 28 entries = [ ( QtGui.QPen(QtGui.QColor("#93c5fd"), 2, QtCore.Qt.PenStyle.DashLine), "Reference", ), (QtGui.QPen(QtGui.QColor(detected_color), 2), "Detected"), ] metrics = QtGui.QFontMetrics(painter.font()) text_y = legend_y + 5 entry_widths = [ line_width + text_gap + metrics.horizontalAdvance(label) for _, label in entries ] total_width = sum(entry_widths) + entry_gap * (len(entries) - 1) x = rect.center().x() - total_width / 2 painter.setPen(QtGui.QColor("#64748b")) for index, (line_pen, label) in enumerate(entries): painter.setPen(line_pen) painter.drawLine(int(x), legend_y, int(x + line_width), legend_y) painter.setPen(QtGui.QColor("#64748b")) painter.drawText(int(x + line_width + text_gap), text_y, label) x += entry_widths[index] + entry_gap def _camera_label_rect(point: QtCore.QPointF, angle_deg: float) -> QtCore.QRectF: width, height = 56.0, 18.0 if angle_deg == -90: return QtCore.QRectF( point.x() - width / 2, point.y() - height - 6, width, height ) if angle_deg == 90: return QtCore.QRectF(point.x() - width / 2, point.y() + 6, width, height) if angle_deg == 0: return QtCore.QRectF(point.x() + 8, point.y() - height / 2, width, height) return QtCore.QRectF(point.x() - width - 8, point.y() - height / 2, width, height)