feat: camera session badge and collapsible legend

The camera overlay gains a clickable session badge (opens the session
menu at the click position) and a baton gate: watching stays free,
operating clicks are blocked when the baton is elsewhere. The legend
collapses to a ? badge until clicked - the full box covered too much
image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 10:35:46 +02:00
co-authored by Claude Fable 5
parent 5f44be2a62
commit 2054f078b0
2 changed files with 84 additions and 6 deletions
+73 -1
View File
@@ -75,6 +75,7 @@ class SampleCameraImageState(Enum):
class SampleCameraImageLabel(QGraphicsView):
smargon = Signal(SmargonCoordinate)
session_badge_clicked = Signal()
evaluate_grid = Signal()
clear_grid = Signal()
@@ -107,6 +108,9 @@ class SampleCameraImageLabel(QGraphicsView):
self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0)
self._is_daq_busy = False
self._camera_available = True
# Baton gate: watching allowed, operating not (main_window drives it).
self._operations_allowed = True
self._session_badge_rect: QRect | None = None # viewport coords
self._last_grid_update_ts = 0.0
self._grid_update_min_interval_s = 1.0 / 25.0
self._tell_state = None
@@ -127,6 +131,10 @@ class SampleCameraImageLabel(QGraphicsView):
self._show_target_coordinates = True
self._show_overlay_legend = True
self._compact_overlay_legend = False
# Legend stays collapsed to a "?" badge until clicked — the full box
# covers too much of the camera image to be always-on.
self._legend_expanded = False
self._legend_hit_rect: QRectF | None = None # viewport coords, set on paint
self._target_point = None
self._target_shape = None
self._target_color_name = "Cyan"
@@ -199,7 +207,14 @@ class SampleCameraImageLabel(QGraphicsView):
)
def _camera_interaction_enabled(self) -> bool:
return self._camera_available
# Watching is free; clicking (targets, raster, smargon moves) needs
# the camera AND the session baton.
return self._camera_available and self._operations_allowed
@Slot(bool)
def set_operations_enabled(self, enabled: bool):
self._operations_allowed = enabled
self.update()
@Slot(bool)
def set_camera_available(self, available: bool):
@@ -303,6 +318,7 @@ class SampleCameraImageLabel(QGraphicsView):
painter.restore()
def _draw_session_overlay(self, painter: QPainter):
self._session_badge_rect = None
if self._busy_overlay_style is not None:
return
@@ -343,6 +359,8 @@ class SampleCameraImageLabel(QGraphicsView):
position_y = int((vh - bg_h) / 2)
bg_rect = QRect(position_x, position_y, bg_w, bg_h)
# Clicking the badge opens the session (grab/request) menu.
self._session_badge_rect = bg_rect
painter.setPen(QPen(qcolor(WHITE, 220)))
painter.setBrush(bg_color)
@@ -406,6 +424,31 @@ class SampleCameraImageLabel(QGraphicsView):
self._scaling()
def mousePressEvent(self, event):
# Legend badge first: pure UI affordance, must work even when camera
# interaction is disabled (session overlay etc.).
if (
event.button() == Qt.MouseButton.LeftButton
and self._legend_hit_rect is not None
and self._legend_hit_rect.contains(
QPointF(self.viewport().mapFrom(self, event.pos()))
)
):
self._legend_expanded = not self._legend_expanded
self.update()
event.accept()
return
# Session badge: the way IN when everything else is gated — must fire
# before the interaction-enabled check below.
if (
event.button() == Qt.MouseButton.LeftButton
and self._session_badge_rect is not None
and self._session_badge_rect.contains(self.viewport().mapFrom(self, event.pos()))
):
self.session_badge_clicked.emit()
event.accept()
return
if not self._camera_interaction_enabled():
if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton):
self._show_camera_unavailable_tooltip(event)
@@ -930,10 +973,38 @@ class SampleCameraImageLabel(QGraphicsView):
return lines
def _draw_legend_badge(self, painter: QPainter):
# ponytail: painted circle, not a real QWidget button — the legend it
# toggles is painter-drawn too, and a widget would need layout juggling.
diameter = 22
rect = QRectF(18, self.viewport().height() - diameter - 18, diameter, diameter)
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setPen(QPen(qcolor(WHITE, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawEllipse(rect)
font = QFont()
font.setPointSize(10)
font.setBold(True)
painter.setFont(font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?")
painter.restore()
self._legend_hit_rect = rect
def _draw_overlay_legend(self, painter: QPainter):
self._legend_hit_rect = None
if not self._legend_should_show():
return
if not self._legend_expanded:
self._draw_legend_badge(painter)
return
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
@@ -959,6 +1030,7 @@ class SampleCameraImageLabel(QGraphicsView):
height = len(lines) * line_height + 16
bg_rect = QRectF(left, max(18, top), width, height)
self._legend_hit_rect = bg_rect # click anywhere on the box to collapse
painter.setPen(QPen(qcolor(WHITE, 60), 1))
painter.setBrush(qcolor(LEGEND_BG, 170))
painter.drawRoundedRect(bg_rect, 8, 8)
+11 -5
View File
@@ -334,7 +334,7 @@ class StatusBar(QStatusBar):
self.session_label.setText(text)
def show_session_menu(self):
def show_session_menu(self, global_pos: QPoint | None = None):
menu = QMenu(self)
is_busy = self._status and self._status.busy
session_state = self._status.session.session if self._status else SessionsStateEnum.Vacant
@@ -413,10 +413,16 @@ class StatusBar(QStatusBar):
action_force = menu.addAction("⚠️ Force Take Over")
action_force.triggered.connect(self._on_force_session_clicked)
label_geometry = self.session_label.geometry()
menu_width = max(label_geometry.width(), menu.sizeHint().width())
menu.move(self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height()))
menu.setFixedWidth(menu_width)
if global_pos is not None:
# Invoked from the camera's session badge — open at the click.
menu.move(global_pos)
else:
label_geometry = self.session_label.geometry()
menu_width = max(label_geometry.width(), menu.sizeHint().width())
menu.move(
self.mapToGlobal(label_geometry.topLeft()) - QPoint(0, menu.sizeHint().height())
)
menu.setFixedWidth(menu_width)
menu.exec()
def show_pgroup_menu(self):