From 2054f078b0d9ad3e982cbace0ccf34dc6c02b562 Mon Sep 17 00:00:00 2001 From: Dawn Date: Fri, 7 Aug 2026 17:08:40 +0200 Subject: [PATCH] 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 --- src/aare/gui/widgets/camera_image.py | 74 +++++++++++++++++++++++++++- src/aare/gui/widgets/status_bar.py | 16 ++++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 58014244..dc225ab8 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -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) diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 00466b6b..aca8d4d1 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -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):