from PySide6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QPushButton, QScrollArea, QStackedWidget, ) 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())