diff --git a/scripts/gui_desginer.py b/scripts/gui_desginer.py new file mode 100644 index 00000000..228a503c --- /dev/null +++ b/scripts/gui_desginer.py @@ -0,0 +1,586 @@ +from PySide6.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QFrame, QPushButton, QScrollArea, QStackedWidget, + QSizePolicy, +) +from PySide6.QtCore import Qt, QPointF, QRectF +from PySide6.QtGui import ( + QPainter, QColor, QPen, QLinearGradient, + QFont, QFontMetrics, +) +import sys +import math + + +# --------------------------------------------------------------------------- +# Colour palette +# --------------------------------------------------------------------------- +BG = "#071018" +CARD_BG = "#0E1A26" +ACCENT = "#62D8C8" +ACCENT_DIM = "#1A3A36" +TEXT = "#F5F7FA" +SUBTEXT = "#8A9BB0" +BUTTON_BG = "#132131" +LED_OFF = "#1C2E3E" +LED_ON = ACCENT +ACTIVE_STEP = "#FFFFFF" + + +STYLE = f""" +QWidget {{ + background: {BG}; + color: {TEXT}; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; +}} +QScrollArea {{ + border: none; + background: transparent; +}} +QScrollBar:vertical {{ + background: {CARD_BG}; + width: 4px; + border-radius: 2px; +}} +QScrollBar::handle:vertical {{ + background: {ACCENT_DIM}; + border-radius: 2px; + min-height: 20px; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; +}} +""" + + +# --------------------------------------------------------------------------- +# Orbit / camera placeholder widget +# --------------------------------------------------------------------------- +class OrbitWidget(QWidget): + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + w, h = self.width(), self.height() + cx, cy = w / 2, h / 2 + + # Background gradient + bg = QLinearGradient(0, 0, w, h) + bg.setColorAt(0, QColor("#0D2A2C")) + bg.setColorAt(1, QColor("#0D1E2E")) + p.fillRect(self.rect(), bg) + + # Corner brackets + pen = QPen(QColor(ACCENT)) + pen.setWidth(2) + p.setPen(pen) + size = 28 + margin = 24 + for x, y, dx, dy in [ + (margin, margin, 1, 1), + (w - margin, margin, -1, 1), + (margin, h - margin, 1, -1), + (w - margin, h - margin, -1, -1), + ]: + p.drawLine(x, y, x + dx * size, y) + p.drawLine(x, y, x, y + dy * size) + + # Orbit rings + pen.setWidth(1) + pen.setColor(QColor(ACCENT + "80")) # semi-transparent + p.setPen(pen) + for r in [38, 68, 98]: + p.drawEllipse(QPointF(cx, cy), r, r) + + # Centre dot + p.setBrush(QColor(ACCENT)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), 9, 9) + + # Orbiting dots on outer ring + p.setBrush(QColor("#F5FFFF")) + for angle in range(0, 360, 45): + rad = math.radians(angle) + ox = cx + math.cos(rad) * 98 + oy = cy + math.sin(rad) * 98 + p.drawEllipse(QPointF(ox, oy), 4, 4) + + +# --------------------------------------------------------------------------- +# LED step indicator +# --------------------------------------------------------------------------- +class LEDStages(QWidget): + STEPS = ["Mount", "Centre", "Raster", "Collect"] + + def __init__(self, active_step: int = 1, parent=None): + """ + active_step: 0-based index of the currently active step. + Steps before active_step are shown as 'done' (filled accent), + active_step is highlighted, later steps are dim. + """ + super().__init__(parent) + self._active = active_step + self.setFixedHeight(72) + + def set_active_step(self, step: int): + self._active = step + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + w = self.width() + n = len(self.STEPS) + step_w = w / n + led_r = 10 + cy = 44 # vertical centre of LEDs + label_y = 16 + + for i, name in enumerate(self.STEPS): + cx = step_w * i + step_w / 2 + + # --- connector line to next step --- + if i < n - 1: + next_cx = step_w * (i + 1) + step_w / 2 + line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF) + pen = QPen(line_color, 2) + p.setPen(pen) + p.drawLine( + QPointF(cx + led_r + 3, cy), + QPointF(next_cx - led_r - 3, cy), + ) + + # --- LED circle --- + p.setPen(Qt.NoPen) + if i < self._active: + # completed + p.setBrush(QColor(ACCENT)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + # tick mark + pen = QPen(QColor(BG), 2) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.drawLine( + QPointF(cx - 4, cy), + QPointF(cx - 1, cy + 3), + ) + p.drawLine( + QPointF(cx - 1, cy + 3), + QPointF(cx + 4, cy - 3), + ) + elif i == self._active: + # active — bright with glow ring + glow_pen = QPen(QColor(ACCENT + "55"), 4) + p.setPen(glow_pen) + p.setBrush(Qt.NoBrush) + p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) + p.setPen(Qt.NoPen) + p.setBrush(QColor(ACTIVE_STEP)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + else: + # pending + p.setBrush(QColor(LED_OFF)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + + # --- label --- + label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT) + p.setPen(label_color) + font = QFont("Inter", 10) + if i == self._active: + font.setWeight(QFont.Weight.DemiBold) + p.setFont(font) + fm = QFontMetrics(font) + text_w = fm.horizontalAdvance(name) + p.drawText( + QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), + Qt.AlignCenter, + name, + ) + + +# --------------------------------------------------------------------------- +# Transport / control button +# --------------------------------------------------------------------------- +class ControlButton(QPushButton): + def __init__(self, symbol: str, primary: bool = False, parent=None): + super().__init__(parent) + self._symbol = symbol + self._primary = primary + self._hovered = False + size = 64 if primary else 52 + self.setFixedSize(size, size) + self.setMouseTracking(True) + + def enterEvent(self, event): + self._hovered = True + self.update() + + def leaveEvent(self, event): + self._hovered = False + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = self.rect() + cx, cy = rect.width() / 2, rect.height() / 2 + r = min(rect.width(), rect.height()) / 2 - 2 + + if self._primary: + if self._hovered: + p.setBrush(QColor("#FFFFFF")) + else: + p.setBrush(QColor(ACCENT)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), r, r) + sym_color = QColor(BG) + else: + if self._hovered: + p.setBrush(QColor(ACCENT)) + else: + p.setBrush(QColor(BUTTON_BG)) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), r, r) + sym_color = QColor(TEXT) if not self._hovered else QColor(BG) + + p.setPen(QPen(sym_color, 2)) + font = QFont("Arial", 16 if self._primary else 13) + p.setFont(font) + p.drawText(rect, Qt.AlignCenter, self._symbol) + + +# --------------------------------------------------------------------------- +# Queue item card +# --------------------------------------------------------------------------- +class QueueItemCard(QFrame): + def __init__( + self, + index: int | str, + title: str, + subtitle: str, + frames_done: int = 0, + frames_total: int = 0, + is_next: bool = False, + parent=None, + ): + super().__init__(parent) + self._index = index + self._title = title + self._subtitle = subtitle + self._frames_done = frames_done + self._frames_total = frames_total + self._is_next = is_next + + self.setFixedHeight(72) + self.setStyleSheet(f""" + QFrame {{ + background: {"#112030" if is_next else "#0C1720"}; + border-radius: 14px; + border: {"1px solid " + ACCENT_DIM if is_next else "none"}; + }} + """) + self._build_layout() + + def _build_layout(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(12) + + # Index badge / play icon + badge = QLabel() + badge.setFixedSize(32, 32) + badge.setAlignment(Qt.AlignCenter) + if self._is_next: + badge.setText("▶") + badge.setStyleSheet(f""" + color: {ACCENT}; + background: {ACCENT_DIM}; + border-radius: 16px; + font-size: 12px; + font-weight: bold; + """) + else: + badge.setText(str(self._index)) + badge.setStyleSheet(f""" + color: {SUBTEXT}; + background: {BUTTON_BG}; + border-radius: 16px; + font-size: 12px; + """) + layout.addWidget(badge) + + # Text block + text_col = QVBoxLayout() + text_col.setSpacing(2) + text_col.setContentsMargins(0, 0, 0, 0) + + title_lbl = QLabel(self._title) + title_lbl.setStyleSheet(f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;") + sub_lbl = QLabel(self._subtitle) + sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; background: transparent;") + + text_col.addWidget(title_lbl) + text_col.addWidget(sub_lbl) + layout.addLayout(text_col, stretch=1) + + # Frame count + mini bar + right_col = QVBoxLayout() + right_col.setSpacing(4) + right_col.setContentsMargins(0, 0, 0, 0) + right_col.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + if self._frames_total > 0: + frame_lbl = QLabel(f"{self._frames_done:,} / {self._frames_total:,}") + frame_lbl.setAlignment(Qt.AlignRight) + color = ACCENT if self._is_next else SUBTEXT + frame_lbl.setStyleSheet(f"color: {color}; font-size: 11px; background: transparent;") + right_col.addWidget(frame_lbl) + + bar = MiniProgressBar(self._frames_done, self._frames_total) + right_col.addWidget(bar) + + layout.addLayout(right_col) + + +class MiniProgressBar(QWidget): + def __init__(self, done: int, total: int, parent=None): + super().__init__(parent) + self._done = done + self._total = total + self.setFixedSize(80, 4) + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + w, h = self.width(), self.height() + # background track + p.setBrush(QColor(LED_OFF)) + p.setPen(Qt.NoPen) + p.drawRoundedRect(0, 0, w, h, 2, 2) + # fill + if self._total > 0: + fill_w = max(4, int(w * self._done / self._total)) + p.setBrush(QColor(ACCENT)) + p.drawRoundedRect(0, 0, fill_w, h, 2, 2) + + +# --------------------------------------------------------------------------- +# Main SAM Camera widget +# --------------------------------------------------------------------------- +class SampleCamera(QWidget): + def __init__(self): + super().__init__() + self.setWindowFlags(Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.resize(390, 860) + self.setStyleSheet(STYLE) + + self._stack = QStackedWidget() + self._player_page = self._build_player() + self._queue_page = self._build_queue() + self._stack.addWidget(self._player_page) + self._stack.addWidget(self._queue_page) + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.addWidget(self._stack) + + # ------------------------------------------------------------------ + # Player page + # ------------------------------------------------------------------ + def _build_player(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + # ── Title ────────────────────────────────────────────────────── + title = QLabel("S A M C A M E R A") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet(f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;") + layout.addWidget(title) + + # ── Camera card ──────────────────────────────────────────────── + cam_card = QFrame() + cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") + cam_card_layout = QVBoxLayout(cam_card) + cam_card_layout.setContentsMargins(0, 0, 0, 0) + cam_card_layout.setSpacing(0) + orbit = OrbitWidget() + orbit.setMinimumHeight(220) + cam_card_layout.addWidget(orbit) + layout.addWidget(cam_card) + + # ── Sample name ──────────────────────────────────────────────── + name_lbl = QLabel("Crystal Plate 14 · Well C7") + name_lbl.setStyleSheet(f"color: {TEXT}; font-size: 18px; font-weight: 700;") + sub_lbl = QLabel("Serial MX · 1 kHz · Beamline X06SA") + sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;") + layout.addWidget(name_lbl) + layout.addWidget(sub_lbl) + + # ── LED step indicator ───────────────────────────────────────── + self._leds = LEDStages(active_step=1) # 0=Mount done, 1=Centre active + layout.addWidget(self._leds) + + # ── Transport controls ───────────────────────────────────────── + ctrl_frame = QFrame() + ctrl_frame.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") + ctrl_layout = QHBoxLayout(ctrl_frame) + ctrl_layout.setContentsMargins(16, 12, 16, 12) + ctrl_layout.setSpacing(0) + + buttons = [ + ("⏮", False), + ("⏪", False), + ("⏸", True), # primary / highlighted + ("⏩", False), + ("⏭", False), + ] + for sym, primary in buttons: + btn = ControlButton(sym, primary=primary) + ctrl_layout.addWidget(btn, alignment=Qt.AlignCenter) + if not primary: + ctrl_layout.addStretch(1) + layout.addWidget(ctrl_frame) + + # ── Up next header ───────────────────────────────────────────── + up_next_row = QHBoxLayout() + up_next_lbl = QLabel("UP NEXT") + up_next_lbl.setStyleSheet(f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;") + samples_lbl = QLabel("5 SAMPLES") + samples_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;") + up_next_row.addWidget(up_next_lbl) + up_next_row.addStretch() + up_next_row.addWidget(samples_lbl) + layout.addLayout(up_next_row) + + # ── Queue preview cards ──────────────────────────────────────── + queue_items = [ + ("Crystal Plate 14 · Well C8", "Serial MX · 1 kHz", 4100, 6000, True), + ("Crystal Plate 15 · Grid Scan", "Raster · 10 Hz", 0, 2000, False), + ("Crystal Plate 13 · Well A1", "Serial MX · 1 kHz", 0, 5000, False), + ("Crystal Plate 13 · Well B3", "Serial MX · 1 kHz", 0, 4500, False), + ("Crystal Plate 14 · Well D5", "Serial MX · 1 kHz", 0, 6000, False), + ] + + queue_widget = QWidget() + queue_layout = QVBoxLayout(queue_widget) + queue_layout.setSpacing(6) + queue_layout.setContentsMargins(0, 0, 0, 0) + + for i, (title, sub, done, total, is_next) in enumerate(queue_items): + card = QueueItemCard( + index="▶" if is_next else i + 1, + title=title, + subtitle=sub, + frames_done=done, + frames_total=total, + is_next=is_next, + ) + queue_layout.addWidget(card) + + layout.addWidget(queue_widget) + + # ── View full queue button ──────────────────────────────────── + view_btn = self._accent_button("VIEW FULL QUEUE ☰") + view_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._queue_page)) + layout.addWidget(view_btn) + + return page + + # ------------------------------------------------------------------ + # Queue / full list page + # ------------------------------------------------------------------ + def _build_queue(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + title = QLabel("SAMPLE QUEUE") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet(f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;") + layout.addWidget(title) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + inner = QWidget() + inner_layout = QVBoxLayout(inner) + inner_layout.setSpacing(6) + inner_layout.setContentsMargins(0, 4, 0, 4) + + for i in range(1, 20): + card = QueueItemCard( + index=i, + title=f"Crystal Plate {i} · Well C{i}", + subtitle="Serial MX · 1 kHz", + frames_done=0, + frames_total=6000, + ) + inner_layout.addWidget(card) + + scroll.setWidget(inner) + layout.addWidget(scroll, stretch=1) + + back_btn = self._accent_button("← BACK TO CAMERA") + back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page)) + layout.addWidget(back_btn) + + return page + + # ------------------------------------------------------------------ + # Helper: styled accent button + # ------------------------------------------------------------------ + @staticmethod + def _accent_button(text: str) -> QPushButton: + btn = QPushButton(text) + btn.setFixedHeight(48) + btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: 1.5px solid {ACCENT}; + border-radius: 14px; + color: {ACCENT}; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + }} + QPushButton:hover {{ + background: {ACCENT_DIM}; + }} + QPushButton:pressed {{ + background: {ACCENT}; + color: {BG}; + }} + """) + return btn + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +def export_png(): + app = QApplication.instance() or QApplication(sys.argv) + ui = SampleCamera() + ui.show() + app.processEvents() + + ui._stack.setCurrentWidget(ui._player_page) + app.processEvents() + ui.grab().save("sam_camera_player.png") + + ui._stack.setCurrentWidget(ui._queue_page) + app.processEvents() + ui.grab().save("sam_camera_queue.png") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + ui = SampleCamera() + ui.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1425e45f..507a68a5 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -2,7 +2,7 @@ import time import jwt from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings, QEvent -from PySide6.QtGui import QAction, QPixmap, QKeySequence +from PySide6.QtGui import QAction, QPixmap, QKeySequence, QGuiApplication from PySide6.QtWidgets import ( QMainWindow, QWidget, @@ -47,6 +47,7 @@ from aare.gui.panels.axis_video_panel import AxisVideoPanel from aare.gui.panels.fluorescence_panel import FluorescencePanel from aare.gui.panels.automation_panel import AutomationProgressWidget from aare.gui.panels.compact_automation_panel import CompactAutomationPanel +from aare.gui.panels.portrait_mode import PortraitModePanel #Scan Logic from aare.gui.scan_logic.raster_grid_manager import RasterGridManager @@ -311,6 +312,43 @@ class MainWindow(QMainWindow): self.compact_automation_page_layout.setSpacing(0) self.compact_automation_page_layout.addWidget(self.compact_automation_panel) + # ── Portrait mode page ────────────────────────────────────────── + # Uses a dedicated SampleCameraImageLabel so the compact_sample_camera + # remains available for the existing compact automation view. + self.portrait_sample_camera = SampleCameraImageLabel( + geom=geom, + raster=self.raster, + parent=root_widget, + default_image=default_image, + ) + self.portrait_mode_panel = PortraitModePanel( + sample_camera_widget=self.portrait_sample_camera, + parent=root_widget, + ) + self.portrait_mode_panel.setObjectName("portraitModePage") + self.portrait_mode_panel.setStyleSheet( + self.portrait_mode_panel.styleSheet() + + "QWidget#portraitModePage { background-color: #071018; }" + ) + + self.portrait_mode_page = QWidget(parent=root_widget) + self.portrait_mode_page.setObjectName("portraitModePage") + self.portrait_mode_page.setStyleSheet( + """ + QWidget#portraitModePage { + background-color: #071018; + } + """ + ) + portrait_page_layout = QHBoxLayout(self.portrait_mode_page) + portrait_page_layout.setContentsMargins(0, 0, 0, 0) + portrait_page_layout.setSpacing(0) + self.portrait_mode_page.setFixedWidth(self.portrait_mode_panel.PORTRAIT_WIDTH + 24) + portrait_page_layout.addWidget( + self.portrait_mode_panel, + alignment=Qt.AlignmentFlag.AlignHCenter, + ) + top_widget_layout.addWidget(self.video_tab) self._start_axis_camera_threads() @@ -334,6 +372,7 @@ class MainWindow(QMainWindow): self.compact_automation_panel.show_full_view_requested.connect(self._return_from_compact_automation_view) self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation) + self.tell_samples_dock = QDockWidget("Sample List", self) self.tell_samples_dock.setObjectName("tell_samples_dock") self.tell_samples_dock.setWidget(self.tell_samples) @@ -425,6 +464,16 @@ class MainWindow(QMainWindow): self.job_list_panel.samples_in_queue_changed.connect( self._refresh_compact_queue_preview ) + # Portrait mode: queue size + running state + preview + self.job_list_panel.automation_running_changed.connect( + self.portrait_mode_panel.set_running + ) + self.job_list_panel.samples_in_queue_changed.connect( + self.portrait_mode_panel.set_samples_in_queue + ) + self.job_list_panel.samples_in_queue_changed.connect( + self._refresh_portrait_queue_preview + ) self.automation_progress_panel.set_samples_in_queue( len(self.job_list_panel.table_model.samples) @@ -478,6 +527,7 @@ class MainWindow(QMainWindow): self.content_stack.addWidget(top_widget) self.content_stack.addWidget(self.compact_automation_page) + self.content_stack.addWidget(self.portrait_mode_page) self.content_stack.setCurrentWidget(top_widget) self._standard_main_page = top_widget @@ -523,6 +573,17 @@ class MainWindow(QMainWindow): self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras) self._axis_camera_refresh_timer.start() + self.portrait_mode_panel.wire_to_main_window( + job_list_panel=self.job_list_panel, + tell_samples=self.tell_samples, + ) + self.portrait_mode_panel._back_btn.clicked.connect(self._return_from_portrait_mode) + self.portrait_mode_panel.grab_session_requested.connect(self.status_bar.request_baton) + + # Route alert banner signals through portrait-aware interceptors + self.daq.polled_devices_status.connect(self._portrait_alert_primary) + self.daq.detector_error.connect(self._portrait_alert_secondary) + self.daq.baton_status_changed.connect(self.status_bar.update_baton_status) self.daq.baton_status_changed.connect(self._on_baton_status_changed) self.daq.baton_request_result.connect(self._on_baton_request_result) @@ -530,6 +591,7 @@ class MainWindow(QMainWindow): self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked) self.daq.automation_progress.connect(self.automation_progress_panel.set_progress) self.daq.automation_progress.connect(self.compact_automation_panel.set_progress) + self.daq.automation_progress.connect(self.portrait_mode_panel.set_progress) self.status_bar.baton_request_received.connect(self._show_baton_request_dialog) self.status_bar.baton_request_accepted.connect(self._accept_baton_request) @@ -589,16 +651,20 @@ class MainWindow(QMainWindow): self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"") self.prediction_thread.image.connect(self.sample_camera.update_pixmap) self.prediction_thread.image.connect(self.compact_sample_camera.update_pixmap) + self.prediction_thread.image.connect(self.portrait_sample_camera.update_pixmap) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections) + self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections) self.prediction_thread.prediction.connect(self.prediction_metrics_panel.update_from_prediction) self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) self.prediction_thread.target_point.connect(self.compact_sample_camera.update_target_point) + self.prediction_thread.target_point.connect(self.portrait_sample_camera.update_target_point) self.prediction_thread.target_point.connect(self.target_stability_panel.update_target_point) self.prediction_thread.focus_measure.connect(self.status_bar.update_sharpness) self.prediction_thread.fps_measure.connect(self.status_bar.update_samcam_fps) self.prediction_thread.camera_availability_changed.connect(self.sample_camera.set_camera_available) self.prediction_thread.camera_availability_changed.connect(self.compact_sample_camera.set_camera_available) + self.prediction_thread.camera_availability_changed.connect(self.portrait_sample_camera.set_camera_available) self.prediction_thread.camera_availability_changed.connect(self._on_sample_camera_availability_changed) self.prediction_thread.camera_error.connect(self._on_sample_camera_error) self.prediction_thread.start() @@ -606,6 +672,7 @@ class MainWindow(QMainWindow): self.prediction_thread = None self.sample_camera.set_camera_available(False) self.compact_sample_camera.set_camera_available(False) + self.portrait_sample_camera.set_camera_available(False) self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # @@ -702,6 +769,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.raster.update_daq_status) self.daq.update.connect(self.sample_camera.update_daq_status) self.daq.update.connect(self.compact_sample_camera.update_daq_status) + self.daq.update.connect(self.portrait_sample_camera.update_daq_status) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: @@ -745,12 +813,6 @@ class MainWindow(QMainWindow): self.daq.fluorimeter_spectrum_update.connect(lambda: self.fluor_panel_dock.setVisible(True)) # === Alert/Status Message Routing === - # Primary alert banner: Infrastructure devices (Server/Tell/Smargon/Aerotech) - self.daq.polled_devices_status.connect(self.alert_banner.show_message) - - # Secondary alert banner: Detector errors (JFJoch) - self.daq.detector_error.connect(self.alert_banner_secondary.show_message) - # Status bar: General status messages (not device connection status) self.daq.status_message.connect(self.status_bar.show_connection_message) @@ -1000,6 +1062,139 @@ class MainWindow(QMainWindow): next_next_sample, ) + @Slot() + def enter_portrait_mode(self) -> None: + """Switch to the portrait / phone-screen view and resize the window.""" + self._pre_portrait_geometry = self.saveGeometry() + + self.portrait_mode_panel.set_running(self.job_list_panel.is_running()) + self.portrait_mode_panel.set_samples_in_queue( + len(self.job_list_panel.table_model.samples) + ) + self._refresh_portrait_queue_preview() + self.content_stack.setCurrentWidget(self.portrait_mode_page) + + # ── Camera: scale-to-fit + hide legend ───────────────────────────── + self.portrait_sample_camera.set_show_overlay_legend(False) + try: + self.portrait_sample_camera._SampleCameraImageLabel__autoscale = True + self.portrait_sample_camera._SampleCameraImageLabel__scaling() + except Exception: + pass + + # ── Hide all chrome that contributes to window width ──────────────── + if self.status_bar is not None: + self.status_bar.setVisible(False) + self.menuBar().setVisible(False) + + # Alert banners take up horizontal space even when hidden via QFrame + # — force them to zero height so they cannot influence the minimum width. + self.alert_banner.setVisible(False) + self.alert_banner.setMaximumHeight(0) + self.alert_banner_secondary.setVisible(False) + self.alert_banner_secondary.setMaximumHeight(0) + + # Hide all dock widgets + for dock_attr in ( + "tell_samples_dock", + "job_list_dock", + "manual_sample_dock", + "automation_progress_dock", + "face_panel_dock", + "fluor_panel_dock", + "smargon_trace_dock", + "target_stability_dock", + "prediction_metrics_dock", + "log_dock", + "ref_tools_dock", + ): + dock = getattr(self, dock_attr, None) + if dock is not None: + dock.setVisible(False) + + # ── Resize to phone footprint ─────────────────────────────────────── + screen = QGuiApplication.screenAt(self.geometry().center()) + if screen is None: + screen = QGuiApplication.primaryScreen() + + available = screen.availableGeometry() + portrait_w = self.portrait_mode_panel.PORTRAIT_WIDTH + 24 + portrait_h = min(860, available.height() - 40) + + new_x = available.x() + (available.width() - portrait_w) // 2 + new_y = available.y() + (available.height() - portrait_h) // 2 + + self.setMinimumWidth(portrait_w) + self.setMaximumWidth(portrait_w) + self.resize(portrait_w, portrait_h) + self.move(new_x, new_y) + + @Slot() + def _return_from_portrait_mode(self) -> None: + """Restore the window to its pre-portrait geometry and switch page.""" + # ── Lift hard width cap before restoring geometry ─────────────────── + self.setMinimumWidth(0) + self.setMaximumWidth(16777215) # Qt's QWIDGETSIZE_MAX + + self.content_stack.setCurrentWidget(self._standard_main_page) + + # ── Restore chrome ────────────────────────────────────────────────── + if self.status_bar is not None: + self.status_bar.setVisible(True) + self.menuBar().setVisible(True) + + # Restore alert banners to normal operation + self.alert_banner.setMaximumHeight(16777215) + self.alert_banner_secondary.setMaximumHeight(16777215) + # Replay any pending messages that arrived during portrait mode + self.portrait_mode_panel._flush_portrait_alerts_to_banners( + self.alert_banner, self.alert_banner_secondary + ) + + # ── Restore camera legend ─────────────────────────────────────────── + try: + settings = self.portrait_sample_camera.target_overlay_settings() + self.portrait_sample_camera.set_show_overlay_legend( + settings.get("show_overlay_legend", True) + ) + except Exception: + pass + + if hasattr(self, "_pre_portrait_geometry") and self._pre_portrait_geometry: + self.restoreGeometry(self._pre_portrait_geometry) + self._pre_portrait_geometry = None + + self.tell_samples_dock.setVisible(True) + self.job_list_dock.setVisible(True) + self.manual_sample_dock.setVisible(True) + self.automation_progress_dock.setVisible(False) + self.face_panel_dock.setVisible(False) + self.fluor_panel_dock.setVisible(False) + self.smargon_trace_dock.setVisible(False) + self.target_stability_dock.setVisible(False) + self.prediction_metrics_dock.setVisible(False) + self.log_dock.setVisible(False) + + @Slot(str, bool) + def _portrait_alert_primary(self, msg: str, is_error: bool) -> None: + """Route primary alert banner — use in-panel toast in portrait mode.""" + if self.content_stack.currentWidget() is self.portrait_mode_page: + self.portrait_mode_panel.show_portrait_alert(msg, is_error) + else: + self.alert_banner.show_message(msg, is_error) + + @Slot(str, bool) + def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None: + """Route secondary alert banner — use in-panel toast in portrait mode.""" + if self.content_stack.currentWidget() is self.portrait_mode_page: + self.portrait_mode_panel.show_portrait_alert(msg, is_error) + else: + self.alert_banner_secondary.show_message(msg, is_error) + + @Slot() + def _refresh_portrait_queue_preview(self) -> None: + self.portrait_mode_panel.refresh_queue_preview() + @staticmethod def _annotation_token(annotation: str) -> str: mapping = { @@ -1079,6 +1274,11 @@ class MainWindow(QMainWindow): self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view) menu_bar.addAction(self._return_main_view_action) + self._portrait_mode_action = QAction("Portrait Mode", self) + self._portrait_mode_action.setShortcut(QKeySequence("Ctrl+6")) + self._portrait_mode_action.triggered.connect(self.enter_portrait_mode) + menu_bar.addAction(self._portrait_mode_action) + view_menu = menu_bar.addMenu("View") if self._beamline_state_panel_enabled: diff --git a/src/aare/gui/panels/portrait_mode.py b/src/aare/gui/panels/portrait_mode.py new file mode 100644 index 00000000..d5c1a977 --- /dev/null +++ b/src/aare/gui/panels/portrait_mode.py @@ -0,0 +1,695 @@ +from __future__ import annotations + +import math + +from PySide6.QtCore import Qt, QPointF, QRectF, Signal, Slot, QTimer +from PySide6.QtGui import ( + QColor, QFont, QFontMetrics, QLinearGradient, QPainter, QPen, +) +from PySide6.QtWidgets import ( + QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, + QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, +) + +from aare.common.automation_models import AutomationProgress, StepStatus, WorkflowStateKind + +# --------------------------------------------------------------------------- +# Colour palette (kept identical to gui_designer.py) +# --------------------------------------------------------------------------- +BG = "#071018" +CARD_BG = "#0E1A26" +ACCENT = "#62D8C8" +ACCENT_DIM = "#1A3A36" +TEXT = "#F5F7FA" +SUBTEXT = "#8A9BB0" +BUTTON_BG = "#132131" +LED_OFF = "#1C2E3E" +ACTIVE_STEP = "#FFFFFF" + +PORTRAIT_STYLE = f""" +QWidget {{ + background: {BG}; + color: {TEXT}; + font-family: 'Inter', 'SF Pro Display', Arial, sans-serif; + font-size: 14px; +}} +QScrollArea {{ + border: none; + background: transparent; +}} +QScrollBar:vertical {{ + background: {CARD_BG}; + width: 4px; + border-radius: 2px; +}} +QScrollBar::handle:vertical {{ + background: {ACCENT_DIM}; + border-radius: 2px; + min-height: 20px; +}} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; +}} +""" + + +# --------------------------------------------------------------------------- +# LED step indicator +# --------------------------------------------------------------------------- +class LEDStages(QWidget): + STEPS = ["Mount", "Centre", "Raster", "Collect"] + + # WorkflowStateKind → LED index + _KIND_TO_INDEX: dict[WorkflowStateKind, int] = { + WorkflowStateKind.MOUNT: 0, + WorkflowStateKind.LOOP_CENTRE: 1, + WorkflowStateKind.RASTER: 2, + WorkflowStateKind.DATA_COLLECTION: 3, + } + + def __init__(self, active_step: int = 0, parent=None): + super().__init__(parent) + self._active = active_step + self.setFixedHeight(72) + + def set_active_step(self, step: int) -> None: + self._active = step + self.update() + + def set_from_progress(self, progress: AutomationProgress) -> None: + """Derive active LED index from an AutomationProgress object.""" + running_index = -1 + last_success = -1 + + for step_state in progress.steps: + idx = self._KIND_TO_INDEX.get(step_state.step) + if idx is None: + continue + if step_state.status == StepStatus.RUNNING: + running_index = idx + elif step_state.status == StepStatus.SUCCESS and idx > last_success: + last_success = idx + + if running_index >= 0: + self.set_active_step(running_index) + elif last_success >= 0: + self.set_active_step(min(last_success + 1, len(self.STEPS) - 1)) + else: + self.set_active_step(0) + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + w = self.width() + n = len(self.STEPS) + step_w = w / n + led_r = 10 + cy = 44 + label_y = 16 + + for i, name in enumerate(self.STEPS): + cx = step_w * i + step_w / 2 + + if i < n - 1: + next_cx = step_w * (i + 1) + step_w / 2 + line_color = QColor(ACCENT) if i < self._active else QColor(LED_OFF) + pen = QPen(line_color, 2) + p.setPen(pen) + p.drawLine( + QPointF(cx + led_r + 3, cy), + QPointF(next_cx - led_r - 3, cy), + ) + + p.setPen(Qt.NoPen) + if i < self._active: + p.setBrush(QColor(ACCENT)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + pen = QPen(QColor(BG), 2) + pen.setCapStyle(Qt.RoundCap) + p.setPen(pen) + p.drawLine(QPointF(cx - 4, cy), QPointF(cx - 1, cy + 3)) + p.drawLine(QPointF(cx - 1, cy + 3), QPointF(cx + 4, cy - 3)) + elif i == self._active: + glow_pen = QPen(QColor(ACCENT + "55"), 4) + p.setPen(glow_pen) + p.setBrush(Qt.NoBrush) + p.drawEllipse(QPointF(cx, cy), led_r + 4, led_r + 4) + p.setPen(Qt.NoPen) + p.setBrush(QColor(ACTIVE_STEP)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + else: + p.setBrush(QColor(LED_OFF)) + p.drawEllipse(QPointF(cx, cy), led_r, led_r) + + label_color = QColor(ACCENT) if i <= self._active else QColor(SUBTEXT) + p.setPen(label_color) + font = QFont("Inter", 10) + if i == self._active: + font.setWeight(QFont.Weight.DemiBold) + p.setFont(font) + fm = QFontMetrics(font) + text_w = fm.horizontalAdvance(name) + p.drawText( + QRectF(cx - text_w / 2 - 4, 0, text_w + 8, label_y + 2), + Qt.AlignCenter, + name, + ) + + +# --------------------------------------------------------------------------- +# Play/Pause primary button +# --------------------------------------------------------------------------- +class PlayPauseButton(QPushButton): + def __init__(self, parent=None): + super().__init__(parent) + self._hovered = False + self._running = False + self.setFixedSize(64, 64) + self.setMouseTracking(True) + + def set_running(self, running: bool) -> None: + self._running = running + self.update() + + def enterEvent(self, event): + self._hovered = True + self.update() + + def leaveEvent(self, event): + self._hovered = False + self.update() + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + rect = self.rect() + cx, cy = rect.width() / 2, rect.height() / 2 + r = min(rect.width(), rect.height()) / 2 - 2 + + bg_color = QColor("#FFFFFF") if self._hovered else QColor(ACCENT) + p.setBrush(bg_color) + p.setPen(Qt.NoPen) + p.drawEllipse(QPointF(cx, cy), r, r) + + sym = "⏸" if self._running else "▶" + p.setPen(QPen(QColor(BG), 2)) + font = QFont("Arial", 16) + p.setFont(font) + p.drawText(rect, Qt.AlignCenter, sym) + + +# --------------------------------------------------------------------------- +# Queue item card +# --------------------------------------------------------------------------- +class QueueItemCard(QFrame): + def __init__( + self, + index: int | str, + title: str, + subtitle: str, + is_next: bool = False, + parent=None, + ): + super().__init__(parent) + self.setFixedHeight(72) + self.setStyleSheet(f""" + QFrame {{ + background: {"#112030" if is_next else "#0C1720"}; + border-radius: 14px; + border: {"1px solid " + ACCENT_DIM if is_next else "none"}; + }} + """) + + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(12) + + badge = QLabel() + badge.setFixedSize(32, 32) + badge.setAlignment(Qt.AlignCenter) + if is_next: + badge.setText("▶") + badge.setStyleSheet(f""" + color: {ACCENT}; background: {ACCENT_DIM}; + border-radius: 16px; font-size: 12px; font-weight: bold; + """) + else: + badge.setText(str(index)) + badge.setStyleSheet(f""" + color: {SUBTEXT}; background: {BUTTON_BG}; + border-radius: 16px; font-size: 12px; + """) + layout.addWidget(badge) + + text_col = QVBoxLayout() + text_col.setSpacing(2) + text_col.setContentsMargins(0, 0, 0, 0) + + title_lbl = QLabel(title) + title_lbl.setStyleSheet( + f"color: {TEXT}; font-size: 13px; font-weight: 600; background: transparent;" + ) + title_lbl.setWordWrap(False) + sub_lbl = QLabel(subtitle) + sub_lbl.setStyleSheet( + f"color: {SUBTEXT}; font-size: 11px; background: transparent;" + ) + text_col.addWidget(title_lbl) + text_col.addWidget(sub_lbl) + layout.addLayout(text_col, stretch=1) + + +# --------------------------------------------------------------------------- +# Main portrait-mode panel +# --------------------------------------------------------------------------- +class PortraitModePanel(QWidget): + PORTRAIT_WIDTH = 420 + grab_session_requested = Signal() + + def __init__(self, sample_camera_widget: QWidget, parent=None): + super().__init__(parent) + self.setStyleSheet(PORTRAIT_STYLE) + self.setMaximumWidth(self.PORTRAIT_WIDTH) + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + + self._job_list_panel = None + self._tell_samples = None + self._is_running = False + + # Pending alert messages received while in portrait mode + # Each entry: (msg, is_error) + self._pending_alerts: list[tuple[str, bool]] = [] + + self._stack = QStackedWidget() + self._player_page = self._build_player(sample_camera_widget) + self._queue_page = self._build_queue() + self._stack.addWidget(self._player_page) + self._stack.addWidget(self._queue_page) + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.addWidget(self._stack) + + # ------------------------------------------------------------------ + # Player page + # ------------------------------------------------------------------ + def _build_player(self, cam_widget: QWidget) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + # Title + title = QLabel("S A M C A M E R A") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet( + f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 600;" + ) + layout.addWidget(title) + + # ── Portrait alert toast (hidden by default) ─────────────────────── + self._alert_toast = QFrame() + self._alert_toast.setVisible(False) + self._alert_toast.setStyleSheet(f""" + QFrame {{ + background: #1A0E0E; + border: 1px solid #8f1d2c; + border-radius: 10px; + }} + """) + toast_layout = QHBoxLayout(self._alert_toast) + toast_layout.setContentsMargins(12, 8, 12, 8) + self._alert_toast_label = QLabel("") + self._alert_toast_label.setWordWrap(True) + self._alert_toast_label.setStyleSheet( + f"color: #ffb3bc; font-size: 11px; font-weight: 600; background: transparent;" + ) + toast_layout.addWidget(self._alert_toast_label) + # Dismiss button + dismiss_btn = QPushButton("✕") + dismiss_btn.setFixedSize(20, 20) + dismiss_btn.setStyleSheet(f""" + QPushButton {{ + color: {SUBTEXT}; + background: transparent; + border: none; + font-size: 11px; + }} + QPushButton:hover {{ color: {TEXT}; }} + """) + dismiss_btn.clicked.connect(self._dismiss_portrait_alert) + toast_layout.addWidget(dismiss_btn) + layout.addWidget(self._alert_toast) + + self._alert_toast_timer = QTimer(self) + self._alert_toast_timer.setSingleShot(True) + self._alert_toast_timer.timeout.connect(self._dismiss_portrait_alert) + + # Camera card — wraps the real compact_sample_camera + cam_card = QFrame() + cam_card.setStyleSheet(f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}") + cam_card_layout = QVBoxLayout(cam_card) + cam_card_layout.setContentsMargins(4, 4, 4, 4) + cam_card_layout.setSpacing(0) + cam_widget.setMinimumHeight(220) + cam_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + cam_card_layout.addWidget(cam_widget) + layout.addWidget(cam_card) + + # Sample name labels + self._name_lbl = QLabel("—") + self._name_lbl.setStyleSheet( + f"color: {TEXT}; font-size: 18px; font-weight: 700;" + ) + self._sub_lbl = QLabel("No sample queued") + self._sub_lbl.setStyleSheet(f"color: {SUBTEXT}; font-size: 12px;") + layout.addWidget(self._name_lbl) + layout.addWidget(self._sub_lbl) + + # LED step indicator + self._leds = LEDStages(active_step=0) + layout.addWidget(self._leds) + + # Play/Pause button (single, centred) + ctrl_frame = QFrame() + ctrl_frame.setStyleSheet( + f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}" + ) + ctrl_layout = QHBoxLayout(ctrl_frame) + ctrl_layout.setContentsMargins(16, 12, 16, 12) + ctrl_layout.addStretch(1) + self._play_pause_btn = PlayPauseButton() + self._play_pause_btn.clicked.connect(self._on_play_pause_clicked) + ctrl_layout.addWidget(self._play_pause_btn, alignment=Qt.AlignCenter) + ctrl_layout.addStretch(1) + layout.addWidget(ctrl_frame) + + # "UP NEXT" header + up_next_row = QHBoxLayout() + up_next_lbl = QLabel("UP NEXT") + up_next_lbl.setStyleSheet( + f"color: {ACCENT}; font-size: 11px; letter-spacing: 2px; font-weight: 700;" + ) + self._samples_count_lbl = QLabel("0 SAMPLES") + self._samples_count_lbl.setStyleSheet( + f"color: {SUBTEXT}; font-size: 11px; letter-spacing: 1px;" + ) + up_next_row.addWidget(up_next_lbl) + up_next_row.addStretch() + up_next_row.addWidget(self._samples_count_lbl) + layout.addLayout(up_next_row) + + # Preview card container (up to 4 cards) + self._preview_container = QWidget() + self._preview_layout = QVBoxLayout(self._preview_container) + self._preview_layout.setSpacing(6) + self._preview_layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._preview_container) + + # View full queue button + view_btn = self._accent_button("VIEW FULL QUEUE ☰") + view_btn.clicked.connect(self._on_view_full_queue) + layout.addWidget(view_btn) + + # ── Session / utility row ────────────────────────────────────────── + util_row = QHBoxLayout() + util_row.setSpacing(8) + + self._grab_session_btn = self._accent_button("⚡ GRAB SESSION") + self._grab_session_btn.clicked.connect(self.grab_session_requested) + util_row.addWidget(self._grab_session_btn) + + layout.addLayout(util_row) + + # Back to main view button + self._back_btn = self._accent_button("← MAIN VIEW") + layout.addWidget(self._back_btn) + # Connected externally by MainWindow + + return page + + # ------------------------------------------------------------------ + # Queue / full list page + # ------------------------------------------------------------------ + def _build_queue(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(10) + layout.setContentsMargins(0, 0, 0, 0) + + title = QLabel("SAMPLE QUEUE") + title.setAlignment(Qt.AlignCenter) + title.setStyleSheet( + f"color: {ACCENT}; font-size: 13px; letter-spacing: 3px; font-weight: 700;" + ) + layout.addWidget(title) + + self._queue_scroll = QScrollArea() + self._queue_scroll.setWidgetResizable(True) + self._queue_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._queue_inner = QWidget() + self._queue_inner_layout = QVBoxLayout(self._queue_inner) + self._queue_inner_layout.setSpacing(6) + self._queue_inner_layout.setContentsMargins(0, 4, 0, 4) + + self._queue_scroll.setWidget(self._queue_inner) + layout.addWidget(self._queue_scroll, stretch=1) + + back_btn = self._accent_button("← BACK TO CAMERA") + back_btn.clicked.connect(lambda: self._stack.setCurrentWidget(self._player_page)) + layout.addWidget(back_btn) + + return page + + # ------------------------------------------------------------------ + # Wiring + # ------------------------------------------------------------------ + def wire_to_main_window(self, job_list_panel, tell_samples) -> None: + """Call this from MainWindow after both panels are constructed.""" + self._job_list_panel = job_list_panel + self._tell_samples = tell_samples + + # ------------------------------------------------------------------ + # Public update slots + # ------------------------------------------------------------------ + @Slot(object) + def set_progress(self, progress: AutomationProgress) -> None: + """Driven by daq.automation_progress signal.""" + self._leds.set_from_progress(progress) + + @Slot(bool) + def set_running(self, running: bool) -> None: + self._is_running = running + self._play_pause_btn.set_running(running) + + @Slot(int) + def set_samples_in_queue(self, count: int) -> None: + label = f"{count} SAMPLE{'S' if count != 1 else ''}" + self._samples_count_lbl.setText(label) + + def refresh_queue_preview(self) -> None: + """ + Refresh sample name, subtitle, and preview cards from queue_preview(). + Falls back to tell_samples sorted by location when the queue is empty. + """ + if self._job_list_panel is None: + return + + current, nxt, nxt2 = self._job_list_panel.queue_preview() + + # --- sample name / subtitle --- + if current is not None: + name = str(getattr(current, "sample_name", "") or "—") + puck = str(getattr(current, "puck_name", "") or "") + pin = getattr(current, "pin", None) + subtitle_parts = [puck] + if pin is not None: + subtitle_parts.append(f"Pin {pin}") + self._name_lbl.setText(name) + self._sub_lbl.setText(" · ".join(p for p in subtitle_parts if p)) + else: + self._name_lbl.setText("—") + self._sub_lbl.setText("No sample queued") + + # --- preview cards --- + # Clear existing + while self._preview_layout.count(): + item = self._preview_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + previews = [s for s in [current, nxt, nxt2] if s is not None] + + # If queue is empty, suggest from tell_samples sorted by location + if not previews and self._tell_samples is not None: + raw = list(getattr(self._tell_samples.table_model, "samples", [])) + suggested = sorted( + [s for s in raw if getattr(s, "location", None) is not None], + key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", + )[:4] + for i, s in enumerate(suggested): + name = str(getattr(s, "sample_name", "") or f"Sample {i + 1}") + puck = str(getattr(s, "puck_name", "") or "") + card = QueueItemCard( + index=i + 1, + title=name, + subtitle=puck, + is_next=False, + ) + self._preview_layout.addWidget(card) + return + + for i, sample in enumerate(previews): + name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") + puck = str(getattr(sample, "puck_name", "") or "") + card = QueueItemCard( + index="▶" if i == 0 else i + 1, + title=name, + subtitle=puck, + is_next=(i == 0), + ) + self._preview_layout.addWidget(card) + + # ------------------------------------------------------------------ + # Full queue page population + # ------------------------------------------------------------------ + def _on_view_full_queue(self) -> None: + self._rebuild_full_queue() + self._stack.setCurrentWidget(self._queue_page) + + def _rebuild_full_queue(self) -> None: + # Clear existing cards + while self._queue_inner_layout.count(): + item = self._queue_inner_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + samples = [] + + if self._job_list_panel is not None: + raw = list(getattr(self._job_list_panel.table_model, "samples", [])) + samples = raw + + # Fall back to tell_samples sorted by location if queue is empty + if not samples and self._tell_samples is not None: + raw = list(getattr(self._tell_samples.table_model, "samples", [])) + samples = sorted( + [s for s in raw if getattr(s, "location", None) is not None], + key=lambda s: s.loc_str_sort() if hasattr(s, "loc_str_sort") else "", + ) + + if not samples: + placeholder = QLabel("No samples in queue") + placeholder.setStyleSheet(f"color: {SUBTEXT}; font-size: 13px;") + placeholder.setAlignment(Qt.AlignCenter) + self._queue_inner_layout.addWidget(placeholder) + return + + for i, sample in enumerate(samples): + name = str(getattr(sample, "sample_name", "") or f"Sample {i + 1}") + puck = str(getattr(sample, "puck_name", "") or "") + card = QueueItemCard( + index=i + 1, + title=name, + subtitle=puck, + is_next=False, + ) + self._queue_inner_layout.addWidget(card) + + # ------------------------------------------------------------------ + # Play/Pause handler + # ------------------------------------------------------------------ + @Slot() + def _on_play_pause_clicked(self) -> None: + if self._job_list_panel is None: + return + if self._is_running: + self._job_list_panel.pause_automation() + else: + self._job_list_panel.run() + + # ------------------------------------------------------------------ + # Helper + # ------------------------------------------------------------------ + @staticmethod + def _accent_button(text: str) -> QPushButton: + btn = QPushButton(text) + btn.setFixedHeight(48) + btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: 1.5px solid {ACCENT}; + border-radius: 14px; + color: {ACCENT}; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.5px; + }} + QPushButton:hover {{ + background: {ACCENT_DIM}; + }} + QPushButton:pressed {{ + background: {ACCENT}; + color: {BG}; + }} + """) + return btn + + # ------------------------------------------------------------------ + # Portrait alert toast + # ------------------------------------------------------------------ + + @Slot(str, bool) + def show_portrait_alert(self, msg: str, is_error: bool) -> None: + """Show a compact dark-themed alert inside the portrait panel.""" + if not msg: + self._dismiss_portrait_alert() + return + + # Store for replay when returning to main view + self._pending_alerts.append((msg, is_error)) + + icon = "🛑" if is_error else "✅" + self._alert_toast_label.setText(f"{icon} {msg}") + + border_color = "#8f1d2c" if is_error else "#2a7a44" + text_color = "#ffb3bc" if is_error else "#a8f0c0" + bg_color = "#1A0E0E" if is_error else "#0E1A12" + + self._alert_toast.setStyleSheet(f""" + QFrame {{ + background: {bg_color}; + border: 1px solid {border_color}; + border-radius: 10px; + }} + """) + self._alert_toast_label.setStyleSheet( + f"color: {text_color}; font-size: 11px; font-weight: 600; background: transparent;" + ) + self._alert_toast.setVisible(True) + + # Auto-dismiss success after 5 s; errors persist until dismissed + self._alert_toast_timer.stop() + if not is_error: + self._alert_toast_timer.start(5000) + + @Slot() + def _dismiss_portrait_alert(self) -> None: + self._alert_toast_timer.stop() + self._alert_toast.setVisible(False) + self._alert_toast_label.clear() + + def _flush_portrait_alerts_to_banners(self, primary_banner, secondary_banner) -> None: + """ + Called when returning to main view — replay any error alerts that + arrived during portrait mode so the operator doesn't miss them. + Only the last error (if any) is surfaced to avoid flooding. + """ + errors = [(m, e) for m, e in self._pending_alerts if e] + if errors: + last_msg, last_is_error = errors[-1] + primary_banner.show_message(last_msg, last_is_error) + self._pending_alerts.clear() + self._dismiss_portrait_alert() \ No newline at end of file diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 42fdfa7b..1d33b830 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -505,6 +505,10 @@ class SampleCameraImageLabel(QGraphicsView): show_coord_action.setCheckable(True) show_coord_action.setChecked(self.__show_coords) + show_detections_action = menu.addAction("Show ML predictions") + show_detections_action.setCheckable(True) + show_detections_action.setChecked(self.__show_detections) + grab_action = menu.addAction("Grab") grab_with_overlay_action = menu.addAction("Grab with overlay") @@ -533,6 +537,9 @@ class SampleCameraImageLabel(QGraphicsView): elif action == scale_action: self.__autoscale = not self.__autoscale self.__scaling() + elif action == show_detections_action: + self.__show_detections = not self.__show_detections + self.update() elif action == delete_action: self.clear_grid.emit() elif action == evaluate_action: