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:
2026-08-11 09:38:46 +02:00
co-authored by Claude Fable 5
parent a9165a604c
commit c95e652dfb
6 changed files with 128 additions and 183 deletions
+14 -49
View File
@@ -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)
-48
View File
@@ -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;
+61 -3
View File
@@ -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,
+9 -40
View File
@@ -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()
+7 -43
View File
@@ -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()
+37
View File
@@ -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