diff --git a/src/aare/gui/panels/axis_video_panel.py b/src/aare/gui/panels/axis_video_panel.py index 2bc5e5e8..4225c65f 100644 --- a/src/aare/gui/panels/axis_video_panel.py +++ b/src/aare/gui/panels/axis_video_panel.py @@ -1,4 +1,6 @@ -from PySide6.QtCore import Qt, Signal +from dataclasses import replace + +from PySide6.QtCore import Signal from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from aare.gui.widgets.busy_overlay import BusyOverlayStyle @@ -8,32 +10,13 @@ from aare.gui.widgets.video_image import VideoGraphicsView class AxisVideoPanel(QWidget): refresh_requested = Signal() - def __init__(self, title: str, video_view: VideoGraphicsView | None = None, parent=None): + # video_view may be a bare VideoGraphicsView or any container holding + # them (the combined view passes a QWidget with two stacked views). + def __init__(self, title: str, video_view: QWidget | None = None, parent=None): super().__init__(parent) self._title_label = QLabel(title, self) - self._status_container = QWidget(self) - self._status_container.setObjectName("axisVideoStatusContainer") - self._status_container.setProperty("busyState", "idle") - - self._status_dot = QLabel(self._status_container) - self._status_dot.setObjectName("axisVideoStatusDot") - self._status_dot.setFixedSize(10, 10) - - self._status_label = QLabel("", self._status_container) - self._status_label.setObjectName("axisVideoStatusLabel") - self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - status_layout = QHBoxLayout(self._status_container) - status_layout.setContentsMargins(10, 6, 12, 6) - status_layout.setSpacing(8) - status_layout.addWidget(self._status_dot) - status_layout.addWidget(self._status_label) - - self._status_container.setMinimumWidth(190) - self._status_container.hide() - self._refresh_button = QPushButton("Refresh Axis Cameras", self) self._refresh_button.clicked.connect(self.refresh_requested.emit) @@ -43,7 +26,6 @@ class AxisVideoPanel(QWidget): controls_layout.setContentsMargins(0, 0, 0, 0) controls_layout.addWidget(self._title_label) controls_layout.addStretch() - controls_layout.addWidget(self._status_container) controls_layout.addWidget(self._refresh_button) root_layout = QVBoxLayout(self) @@ -52,12 +34,6 @@ class AxisVideoPanel(QWidget): root_layout.addLayout(controls_layout) root_layout.addWidget(self.view) - def _refresh_status_style(self) -> None: - for widget in (self._status_container, self._status_dot, self._status_label): - widget.style().unpolish(widget) - widget.style().polish(widget) - widget.update() - def _all_video_views(self) -> list[VideoGraphicsView]: views: list[VideoGraphicsView] = [] if isinstance(self.view, VideoGraphicsView): @@ -70,24 +46,13 @@ class AxisVideoPanel(QWidget): return unique_views def set_busy_style(self, style: BusyOverlayStyle | None) -> None: - if style is None: - self._status_label.setText("") - self._status_container.setProperty("busyState", "idle") - self._status_dot.setStyleSheet("background-color: transparent;") - self._status_label.setStyleSheet("") - self._refresh_status_style() - self._status_container.hide() - else: - self._status_label.setText(style.text) - self._status_container.setProperty("busyState", "active") - self._status_dot.setStyleSheet(f"background-color: {style.accent_dot};") - self._status_label.setStyleSheet(f"color: {style.badge_fg};") - self._refresh_status_style() - self._status_container.show() + # The hint line invites a click, but only the sample-camera badge is + # a click target — strip it for these passive views. + if style is not None and style.subtext: + style = replace(style, subtext="") - for view in self._all_video_views(): + # Only the first view draws the badge: the combined panel stacks two + # video views and used to show the message once per view. + for index, view in enumerate(self._all_video_views()): if hasattr(view, "set_busy_overlay_style"): - view.set_busy_overlay_style(style) - - def set_status_text(self, text: str) -> None: - self._status_label.setText(text or "") + view.set_busy_overlay_style(style if index == 0 else None) diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index ed56d3a7..5dffb881 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -890,30 +890,6 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str: color: $warning_text; } - QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: $card_radius; - background-color: $status_idle_bg; - } - - QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: $card_radius; - } - - QLabel#axisVideoStatusDot { - min-width: 10px; - max-width: 10px; - min-height: 10px; - max-height: 10px; - border-radius: 5px; - background-color: transparent; - } - - QLabel#axisVideoStatusLabel { - background-color: transparent; - color: $status_label_text; - font-weight: bold; - } - /* Check/radio indicators: explicit QSS boxes, both SQUARE for consistency — hollow = unchecked, accent-filled = checked. Native indicator glyphs are unreliable on this Qt once anything nearby is @@ -1633,30 +1609,6 @@ def _sunset_stylesheet() -> str: color: $dark_warning_text; } - QWidget#axisVideoStatusContainer[busyState="idle"] { - border-radius: $card_radius; - background-color: $dark_elevated; - } - - QWidget#axisVideoStatusContainer[busyState="active"] { - border-radius: $card_radius; - } - - QLabel#axisVideoStatusDot { - min-width: 10px; - max-width: 10px; - min-height: 10px; - max-height: 10px; - border-radius: 5px; - background-color: transparent; - } - - QLabel#axisVideoStatusLabel { - background-color: transparent; - color: $dark_subtext; - font-weight: bold; - } - QFrame#beamlineStatePanel { background: transparent; border-top: 1px solid transparent; diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index 0d7a3a0e..6013a7b2 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -2,7 +2,8 @@ from dataclasses import dataclass from aarecommon.models.models import SessionsStateEnum from aarecommon.models.tell import TellStateModel -from PySide6.QtGui import QColor +from PySide6.QtCore import QPoint, QRect, Qt +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen from aare.gui.styles import ( BUSY_BLUE, @@ -39,11 +40,68 @@ class BusyOverlayStyle: overlay_border: QColor overlay_text: QColor accent_dot: str - # Hint line under the title — only the big sample-camera badge draws it; - # compact consumers (axis panel label, video badge) show text alone. + # Hint line under the title. draw_busy_badge renders it whenever set; + # AxisVideoPanel strips it because only the sample-camera badge is a + # click target and the hint invites a click. subtext: str = "" +def draw_busy_badge( + painter: QPainter, + viewport_width: int, + viewport_height: int, + style: BusyOverlayStyle, + *, + fill: QColor | None = None, +) -> QRect: + """The one badge renderer for every camera view — sample camera and the + Axis video views draw the same box so the message reads identically + everywhere (each view used to have its own look). Returns the badge rect + so interactive views can use it as a click target. `fill` overrides the + style's fill (hover feedback).""" + font = QFont() + font.setPointSize(24) + font.setBold(True) + font_metrics = QFontMetrics(font) + + sub_font = QFont() + sub_font.setPointSize(12) + sub_metrics = QFontMetrics(sub_font) + + title_width = font_metrics.horizontalAdvance(style.text) + sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0 + + padding_x = 20 + padding_y = 14 + sub_gap = 6 + bg_width = max(title_width, sub_width) + 2 * padding_x + bg_height = font_metrics.height() + 2 * padding_y + if style.subtext: + bg_height += sub_gap + sub_metrics.height() + + position_x = int((viewport_width - bg_width) / 2) + position_y = int(viewport_height * 0.68 - bg_height / 2) + bg_rect = QRect(position_x, position_y, bg_width, bg_height) + + painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine)) + painter.setBrush(fill if fill is not None else QColor(style.overlay_fill)) + painter.drawRoundedRect(bg_rect, 10, 10) + + painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine)) + painter.setFont(font) + title_x = position_x + (bg_width - title_width) // 2 + title_y = position_y + padding_y + font_metrics.ascent() + painter.drawText(QPoint(title_x, title_y), style.text) + + if style.subtext: + painter.setFont(sub_font) + sub_x = position_x + (bg_width - sub_width) // 2 + sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent() + painter.drawText(QPoint(sub_x, sub_y), style.subtext) + + return bg_rect + + def build_busy_overlay_style( *, is_busy: bool, diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 0f82614b..835aac34 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -63,7 +63,11 @@ from aare.gui.styles import ( WHITE, qcolor, ) -from aare.gui.widgets.busy_overlay import BusyOverlayStyle, build_busy_overlay_style +from aare.gui.widgets.busy_overlay import ( + BusyOverlayStyle, + build_busy_overlay_style, + draw_busy_badge, +) logger = setup_logger(LOGGER_NAME) @@ -341,29 +345,6 @@ class SampleCameraImageLabel(QGraphicsView): painter.restore() return - sub_font = QFont() - sub_font.setPointSize(12) - sub_metrics = QFontMetrics(sub_font) - - title_width = font_metrics.horizontalAdvance(style.text) - sub_width = sub_metrics.horizontalAdvance(style.subtext) if style.subtext else 0 - - padding_x = 20 - padding_y = 14 - sub_gap = 6 - bg_width = max(title_width, sub_width) + 2 * padding_x - bg_height = font_metrics.height() + 2 * padding_y - if style.subtext: - bg_height += sub_gap + sub_metrics.height() - - viewport_width = self.viewport().width() - viewport_height = self.viewport().height() - - position_x = int((viewport_width - bg_width) / 2) - position_y = int(viewport_height * 0.68 - bg_height / 2) - - bg_rect = QRect(position_x, position_y, bg_width, bg_height) - # SESSION VACANT / GUEST MODE badges double as the click target for the # grab/request menu, same as the _draw_session_overlay badge they hide. session_badge = self._session_state in ( @@ -371,28 +352,16 @@ class SampleCameraImageLabel(QGraphicsView): SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse, ) - self._session_badge_rect = bg_rect if session_badge else None fill = QColor(style.overlay_fill) if session_badge and self._session_badge_hovered: # Hover: darker in the light themes, brighter in Sunset. fill = fill.lighter(125) if self._dark_theme else fill.darker(115) - painter.setPen(QPen(style.overlay_border, 2, Qt.PenStyle.SolidLine)) - painter.setBrush(fill) - painter.drawRoundedRect(bg_rect, 10, 10) - - painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine)) - painter.setFont(font) - title_x = position_x + (bg_width - title_width) // 2 - title_y = position_y + padding_y + font_metrics.ascent() - painter.drawText(QPoint(title_x, title_y), style.text) - - if style.subtext: - painter.setFont(sub_font) - sub_x = position_x + (bg_width - sub_width) // 2 - sub_y = position_y + padding_y + font_metrics.height() + sub_gap + sub_metrics.ascent() - painter.drawText(QPoint(sub_x, sub_y), style.subtext) + bg_rect = draw_busy_badge( + painter, self.viewport().width(), self.viewport().height(), style, fill=fill + ) + self._session_badge_rect = bg_rect if session_badge else None painter.restore() diff --git a/src/aare/gui/widgets/video_image.py b/src/aare/gui/widgets/video_image.py index 78d9134d..bfbb52af 100644 --- a/src/aare/gui/widgets/video_image.py +++ b/src/aare/gui/widgets/video_image.py @@ -1,8 +1,8 @@ from PySide6.QtCore import QRectF, Qt, Slot -from PySide6.QtGui import QColor, QFont, QFontMetrics, QImage, QPainter, QPen, QPixmap +from PySide6.QtGui import QImage, QPainter, QPixmap from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView -from aare.gui.widgets.busy_overlay import BusyOverlayStyle +from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_badge class VideoGraphicsView(QGraphicsView): @@ -111,48 +111,12 @@ class VideoGraphicsView(QGraphicsView): if self._busy_overlay_style is None: return - style = self._busy_overlay_style - painter.save() painter.resetTransform() painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - - font = QFont() - font.setPointSize(24) - font.setBold(True) - painter.setFont(font) - - fm = QFontMetrics(font) - text_rect = fm.boundingRect(style.text) - - dot_diameter = 14 - gap = 12 - padding_x = 22 - padding_y = 14 - bg_width = text_rect.width() + dot_diameter + gap + padding_x * 2 - bg_height = max(text_rect.height(), dot_diameter) + padding_y * 2 - - viewport_width = self.viewport().width() - viewport_height = self.viewport().height() - - pos_x = int((viewport_width - bg_width) / 2) - pos_y = int(viewport_height * 0.68 - bg_height / 2) - - bg_rect = QRectF(pos_x, pos_y, bg_width, bg_height) - - painter.setPen(QPen(style.overlay_border, 2)) - painter.setBrush(style.overlay_fill) - painter.drawRoundedRect(bg_rect, 14, 14) - - dot_x = bg_rect.left() + padding_x - dot_y = bg_rect.top() + (bg_rect.height() - dot_diameter) / 2 - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QColor(style.accent_dot)) - painter.drawEllipse(QRectF(dot_x, dot_y, dot_diameter, dot_diameter)) - - painter.setPen(QPen(style.overlay_text, 1)) - text_x = dot_x + dot_diameter + gap - text_y = bg_rect.top() + padding_y + fm.ascent() - painter.drawText(text_x, text_y, style.text) - + # Shared renderer with the sample camera, so every view shows the + # identical badge (this view used to draw its own dot+text variant). + draw_busy_badge( + painter, self.viewport().width(), self.viewport().height(), self._busy_overlay_style + ) painter.restore() diff --git a/tests/unit/gui/test_axis_video_panel.py b/tests/unit/gui/test_axis_video_panel.py new file mode 100644 index 00000000..c73b8146 --- /dev/null +++ b/tests/unit/gui/test_axis_video_panel.py @@ -0,0 +1,37 @@ +from aarecommon.models.models import SessionsStateEnum +from PySide6.QtWidgets import QVBoxLayout, QWidget + +from aare.gui.panels.axis_video_panel import AxisVideoPanel +from aare.gui.widgets.busy_overlay import build_busy_overlay_style +from aare.gui.widgets.video_image import VideoGraphicsView + + +def _vacant_style(): + return build_busy_overlay_style( + is_busy=False, tell_state=None, session_state=SessionsStateEnum.Vacant + ) + + +def test_badge_drawn_once_and_hint_stripped(qtbot): + # Combined-view shape: two video views stacked in one container. + container = QWidget() + layout = QVBoxLayout(container) + first, second = VideoGraphicsView(), VideoGraphicsView() + layout.addWidget(first) + layout.addWidget(second) + + panel = AxisVideoPanel("Combined", container) + qtbot.addWidget(panel) + + style = _vacant_style() + assert style is not None and style.subtext # sample camera keeps the hint + + panel.set_busy_style(style) + applied = first._busy_overlay_style + assert applied is not None + assert applied.text == "In viewing mode" + assert applied.subtext == "" # not clickable here, hint stripped + assert second._busy_overlay_style is None # one badge, not one per view + + panel.set_busy_style(None) + assert first._busy_overlay_style is None