added portrait_mode.py
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user