refactor: shared busy-badge renderer for camera and axis video views
draw_busy_badge in busy_overlay renders the title+subtext pill once; the sample camera and axis video overlays both delegate to it instead of keeping diverging copies. The axis panel's dot+label status pill and its per-theme QSS go away with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user