added portrait_mode.py

This commit is contained in:
appleb_m
2026-06-18 14:05:54 +02:00
parent 087ce0482f
commit e66d8aec72
4 changed files with 1495 additions and 7 deletions
+586
View File
@@ -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())
+207 -7
View File
@@ -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:
+695
View File
@@ -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()
+7
View File
@@ -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: