diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a1475202..1af75820 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1417,6 +1417,14 @@ class MainWindow(QMainWindow): def _on_sample_camera_error(self, message: str) -> None: logger.warning(message) self._show_samcam_feed_banner(message or "Sample camera feed unavailable") + # The camera overlays show the reason too, not just the boolean. + for cam in ( + self.sample_camera, + getattr(self, "compact_sample_camera", None), + getattr(self, "portrait_sample_camera", None), + ): + if cam is not None: + cam.set_camera_error_message(message or "Sample camera feed unavailable") def _show_samcam_feed_banner(self, message: str) -> None: self._samcam_feed_banner_message = message @@ -1769,6 +1777,7 @@ class MainWindow(QMainWindow): self.setStyleSheet(build_app_stylesheet(self._theme_mode)) # State colors are painted in code per DAQ tick — QSS can't reach them. self.beamline_state_panel.set_theme(self._theme_mode) + self.sample_camera.set_theme(self._theme_mode) if old_look is None: return overlay = QLabel(self) diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index d344a2d1..0d7a3a0e 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -39,6 +39,9 @@ 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. + subtext: str = "" def build_busy_overlay_style( @@ -49,13 +52,14 @@ def build_busy_overlay_style( ) -> BusyOverlayStyle | None: if session_state == SessionsStateEnum.Vacant: return BusyOverlayStyle( - text="SESSION VACANT", + text="In viewing mode", badge_bg=BUSY_YELLOW, badge_fg=WHITE, overlay_fill=qcolor(BUSY_YELLOW, 195), overlay_border=qcolor(BUSY_YELLOW_BORDER, 235), overlay_text=qcolor(WHITE), accent_dot=BUSY_YELLOW_DOT, + subtext="Click here to grab baton if need to interact with GUI", ) if session_state in {SessionsStateEnum.OwnedByElse, SessionsStateEnum.PendingYouToElse}: diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 1e117e02..0f82614b 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -1,6 +1,7 @@ import math import time from enum import Enum +from typing import ClassVar from aarecommon.config.logger import setup_logger from aarecommon.math.coordinate import Coordinate, SmargonCoordinate @@ -55,7 +56,9 @@ from aare.gui.styles import ( MARKER_GREEN, PATH_END, PATH_START, + SHADOW, TARGET_COLORS, + THEME_SUNSET, TOOLTIP_TEXT, WHITE, qcolor, @@ -108,9 +111,14 @@ class SampleCameraImageLabel(QGraphicsView): self._sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0) self._is_daq_busy = False self._camera_available = True + self._camera_error_message: str | None = None # Baton gate: watching allowed, operating not (main_window drives it). self._operations_allowed = True self._session_badge_rect: QRect | None = None # viewport coords + self._session_badge_hovered = False + # Hover polarity for the badge: light themes darken, Sunset brightens. + # Set via set_theme from MainWindow._apply_theme. + self._dark_theme = False self._last_grid_update_ts = 0.0 self._grid_update_min_interval_s = 1.0 / 25.0 self._tell_state = None @@ -131,10 +139,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 + # "?" badge is a mouse-controls cheatsheet, decoupled from the legend — + # legend visibility is already handled by the panel checkboxes. + self._help_expanded = False + self._help_hit_rect: QRectF | None = None # viewport coords, set on paint self._target_point = None self._target_shape = None self._target_color_name = "Cyan" @@ -219,9 +227,18 @@ class SampleCameraImageLabel(QGraphicsView): @Slot(bool) def set_camera_available(self, available: bool): self._camera_available = available + if available: + self._camera_error_message = None self._update_camera_interaction_feedback() self.update() + @Slot(str) + def set_camera_error_message(self, message: str): + # Thread errors arrive as "...unavailable: X" — reword to the + # "...unavailable because X" phrasing the overlay shows. + self._camera_error_message = message.replace(": ", " because ", 1) + self.update() + @Slot(dict) def update_detections(self, payload: dict): try: @@ -277,6 +294,17 @@ class SampleCameraImageLabel(QGraphicsView): return f"TELL {activity_name}".upper() + def _draw_status_text( + self, painter: QPainter, text: str, color, center_x: int, baseline_y: int, fm: QFontMetrics + ): + # Solid colored text with a 1px shadow — survives any camera image + # behind it without a badge box. + x = center_x - fm.horizontalAdvance(text) // 2 + painter.setPen(QPen(qcolor(SHADOW, 200))) + painter.drawText(QPoint(x + 1, baseline_y + 1), text) + painter.setPen(QPen(qcolor(color) if isinstance(color, str) else color)) + painter.drawText(QPoint(x, baseline_y), text) + def _draw_busy_overlay(self, painter: QPainter): if self._busy_overlay_style is None: return @@ -289,15 +317,44 @@ class SampleCameraImageLabel(QGraphicsView): font = QFont() font.setPointSize(24) font.setBold(True) - painter.setFont(font) - font_metrics = QFontMetrics(font) - text_rect = font_metrics.boundingRect(style.text) + + # Robot/busy WARNINGS are not clickable: no badge box, just solid + # text in the state's color. Only the session badges (a real click + # target) keep the button-like pill below. + if self._session_state not in ( + SessionsStateEnum.Vacant, + SessionsStateEnum.OwnedByElse, + SessionsStateEnum.PendingYouToElse, + ): + self._session_badge_rect = None + painter.setFont(font) + baseline = int(self.viewport().height() * 0.68) + font_metrics.ascent() // 2 + self._draw_status_text( + painter, + style.text, + style.badge_bg, + self.viewport().width() // 2, + baseline, + font_metrics, + ) + 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 - bg_width = text_rect.width() + 2 * padding_x - bg_height = text_rect.height() + 2 * padding_y + 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() @@ -307,20 +364,43 @@ class SampleCameraImageLabel(QGraphicsView): 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 ( + SessionsStateEnum.Vacant, + 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(style.overlay_fill) + painter.setBrush(fill) painter.drawRoundedRect(bg_rect, 10, 10) painter.setPen(QPen(style.overlay_text, 2, Qt.PenStyle.SolidLine)) - text_pos = QPoint(position_x + padding_x, position_y + padding_y + font_metrics.ascent()) - painter.drawText(text_pos, style.text) + 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) painter.restore() def _draw_session_overlay(self, painter: QPainter): - self._session_badge_rect = None if self._busy_overlay_style is not None: + # Busy overlay drew (and owns) the session badge rect — don't clobber. return + self._session_badge_rect = None if self._session_state in ( SessionsStateEnum.OwnedByYou, @@ -362,6 +442,10 @@ class SampleCameraImageLabel(QGraphicsView): # Clicking the badge opens the session (grab/request) menu. self._session_badge_rect = bg_rect + if self._session_badge_hovered: + # Same hover polarity as the busy-overlay badge. + bg_color = bg_color.lighter(125) if self._dark_theme else bg_color.darker(115) + painter.setPen(QPen(qcolor(WHITE, 220))) painter.setBrush(bg_color) painter.drawRoundedRect(bg_rect, 10, 10) @@ -382,28 +466,21 @@ class SampleCameraImageLabel(QGraphicsView): font.setPointSize(22) font.setBold(True) painter.setFont(font) - - text = "Sample camera feed unavailable" fm = QFontMetrics(font) - text_rect = fm.boundingRect(text) - padding = 16 - position_x = 50 - position_y = 120 - - bg_rect = QRect( - position_x - padding, - position_y - padding, - text_rect.width() + 2 * padding, - text_rect.height() + 2 * padding, + # Bottom-center, no badge box — solid colored text (the pill read + # as a button). The camera thread's reason is appended upstream as + # "... because " when it is known. + margin = 18 + text = fm.elidedText( + self._camera_error_message or "Sample camera feed unavailable", + Qt.TextElideMode.ElideRight, + self.viewport().width() - 2 * margin, + ) + baseline = self.viewport().height() - margin - fm.descent() + self._draw_status_text( + painter, text, MARK_BADGE_BG, self.viewport().width() // 2, baseline, fm ) - - painter.setPen(QPen(qcolor(WHITE, 220), 2)) - painter.setBrush(qcolor(MARK_BADGE_BG, 180)) - painter.drawRoundedRect(bg_rect, 10, 10) - - painter.setPen(QPen(qcolor(WHITE))) - painter.drawText(QPoint(position_x, position_y + fm.ascent()), text) painter.restore() @@ -418,20 +495,21 @@ class SampleCameraImageLabel(QGraphicsView): self._draw_detections(painter, rect) self._draw_target_point(painter) self._draw_overlay_legend(painter) + self._draw_help_overlay(painter) def resizeEvent(self, event): super().resizeEvent(event) self._scaling() def mousePressEvent(self, event): - # Legend badge first: pure UI affordance, must work even when camera + # Help 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()))) + and self._help_hit_rect is not None + and self._help_hit_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos()))) ): - self._legend_expanded = not self._legend_expanded + self._help_expanded = not self._help_expanded self.update() event.accept() return @@ -490,7 +568,27 @@ class SampleCameraImageLabel(QGraphicsView): self.switch_raster_grid.emit() self._raster_mgr.resize_active_grid(self.end_point) + @Slot(str) + def set_theme(self, theme: str): + self._dark_theme = theme == THEME_SUNSET + self.update() + + def leaveEvent(self, event): + if self._session_badge_hovered: + self._session_badge_hovered = False + self.update() + super().leaveEvent(event) + def mouseMoveEvent(self, event): + # Badge hover feedback must run BEFORE the interaction gate: the + # badge is visible precisely when interaction is disabled. + hovered = self._session_badge_rect is not None and self._session_badge_rect.contains( + self.viewport().mapFrom(self, event.pos()) + ) + if hovered != self._session_badge_hovered: + self._session_badge_hovered = hovered + self.update() + if not self._camera_interaction_enabled(): return @@ -900,7 +998,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.setBrush(qcolor(LEGEND_BG, 190)) painter.drawRoundedRect(bubble_rect, 8, 8) - painter.setPen(QPen(qcolor(WHITE), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) painter.drawText( QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), label_text ) @@ -971,8 +1069,32 @@ class SampleCameraImageLabel(QGraphicsView): return lines - def _draw_legend_badge(self, painter: QPainter): - # ponytail: painted circle, not a real QWidget button — the legend it + # (header, [entries]) — kept concise on purpose; the full table lives in + # docs/cheatsheet.md. + _HELP_SECTIONS: ClassVar[list[tuple[str, list[str]]]] = [ + ( + "Sample camera", + [ + "Left click — move sample here", + "Shift + Left click — Z-alignment move", + "Right click — context menu", + "Wheel — rotate omega 90° (Shift: 10°)", + "Ctrl / Alt + Wheel — exposure coarse / fine", + ], + ), + ( + "Raster grid", + [ + "Right drag — draw grid (on grid: resize)", + "Left drag — move grid", + "Ctrl + Left click — move sample under grid", + "Shift + move — inspect raster image at cursor", + ], + ), + ] + + def _draw_help_badge(self, painter: QPainter): + # ponytail: painted circle, not a real QWidget button — the overlay 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) @@ -980,7 +1102,7 @@ class SampleCameraImageLabel(QGraphicsView): painter.save() painter.resetTransform() painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) - painter.setPen(QPen(qcolor(WHITE, 60), 1)) + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawEllipse(rect) @@ -992,15 +1114,62 @@ class SampleCameraImageLabel(QGraphicsView): painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "?") painter.restore() - self._legend_hit_rect = rect + self._help_hit_rect = rect - def _draw_overlay_legend(self, painter: QPainter): - self._legend_hit_rect = None - if not self._legend_should_show(): + def _draw_help_overlay(self, painter: QPainter): + self._help_hit_rect = None + if not self._help_expanded: + self._draw_help_badge(painter) return - if not self._legend_expanded: - self._draw_legend_badge(painter) + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + font = QFont() + font.setPointSize(9) + header_font = QFont(font) + header_font.setBold(True) + fm = QFontMetrics(font) + header_fm = QFontMetrics(header_font) + + line_height = fm.height() + 4 + header_height = header_fm.height() + 6 + padding = 10 + + max_width = 0 + n_lines = 0 + for header, entries in self._HELP_SECTIONS: + max_width = max(max_width, header_fm.horizontalAdvance(header)) + n_lines += len(entries) + for entry in entries: + max_width = max(max_width, fm.horizontalAdvance(entry)) + + width = max_width + padding * 2 + height = len(self._HELP_SECTIONS) * header_height + n_lines * line_height + padding * 2 + + bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height) + self._help_hit_rect = bg_rect # click anywhere on the box to close + painter.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) + painter.setBrush(qcolor(LEGEND_BG, 190)) + painter.drawRoundedRect(bg_rect, 8, 8) + + y = bg_rect.top() + padding + for header, entries in self._HELP_SECTIONS: + painter.setFont(header_font) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) + painter.drawText(QPointF(bg_rect.left() + padding, y + header_fm.ascent()), header) + y += header_height + painter.setFont(font) + painter.setPen(QPen(qcolor(LEGEND_TEXT), 1)) + for entry in entries: + painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry) + y += line_height + + painter.restore() + + def _draw_overlay_legend(self, painter: QPainter): + if not self._legend_should_show() or self._help_expanded: return painter.save() @@ -1018,7 +1187,8 @@ class SampleCameraImageLabel(QGraphicsView): text_padding = 6 if self._compact_overlay_legend else 8 section_padding = 8 if self._compact_overlay_legend else 10 left = 18 - top = self.viewport().height() - (len(lines) * line_height + 24) + # Bottom-anchored above the "?" help badge (18px margin + 22px badge + gap). + top = self.viewport().height() - (len(lines) * line_height + 16) - 48 max_text_width = 0 for text, _color in lines: @@ -1028,8 +1198,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.setPen(QPen(qcolor(LEGEND_TEXT, 60), 1)) painter.setBrush(qcolor(LEGEND_BG, 170)) painter.drawRoundedRect(bg_rect, 8, 8)